feat: Support GET requests on subscription services

Doing a GET request on a subscription resource
will return the expected representation.
Content negotiation is supported.
This commit is contained in:
Joachim Van Herwegen 2023-01-31 11:18:35 +01:00
parent b2f4d7fb2d
commit 65860f77da
27 changed files with 446 additions and 211 deletions

View File

@ -16,10 +16,15 @@
"errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" },
"responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" },
"operationHandler": {
"comment": "New notification subscription types should be added here to allow subscriptions.",
"@id": "urn:solid-server:default:NotificationTypeHandler",
"@type": "WaterfallHandler",
"handlers": [ ]
"comment": "Converts outgoing responses based on the user preferences",
"@type": "ConvertingOperationHttpHandler",
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
"operationHandler": {
"comment": "New notification subscription types should be added here to allow subscriptions.",
"@id": "urn:solid-server:default:NotificationTypeHandler",
"@type": "WaterfallHandler",
"handlers": [ ]
}
}
}
]

View File

@ -19,11 +19,15 @@
"errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" },
"responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" },
"operationHandler": {
"@type": "StorageDescriptionHandler",
"store": { "@id": "urn:solid-server:default:ResourceStore" },
"path": { "@id": "urn:solid-server:default:variable:storageDescriptionPath" },
"comment": "Converts outgoing responses based on the user preferences",
"@type": "ConvertingOperationHttpHandler",
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
"describer": { "@id": "urn:solid-server:default:StorageDescriber" }
"operationHandler": {
"@type": "StorageDescriptionHandler",
"store": { "@id": "urn:solid-server:default:ResourceStore" },
"path": { "@id": "urn:solid-server:default:variable:storageDescriptionPath" },
"describer": { "@id": "urn:solid-server:default:StorageDescriber" }
}
}
}
},

View File

@ -5,11 +5,9 @@
"css:config/http/notifications/base/handler.json",
"css:config/http/notifications/base/listener.json",
"css:config/http/notifications/base/storage.json",
"css:config/http/notifications/websockets/description.json",
"css:config/http/notifications/websockets/handler.json",
"css:config/http/notifications/websockets/http.json",
"css:config/http/notifications/websockets/subscription.json",
"css:config/http/notifications/webhooks/description.json",
"css:config/http/notifications/webhooks/handler.json",
"css:config/http/notifications/webhooks/routes.json",
"css:config/http/notifications/webhooks/subscription.json"

View File

@ -2,10 +2,19 @@
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
"@graph": [
{
"comment": "New notification subscription types should add a handler containing their descriptions so they can be discovered.",
"@id": "urn:solid-server:default:StorageDescriber",
"@type": "ArrayUnionHandler",
"handlers": [ ]
"handlers": [
{
"comment": "New notification subscription types should add a NotificationChannelType here so they can be discovered.",
"@id": "urn:solid-server:default:NotificationDescriber",
"@type": "NotificationDescriber",
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
"subscriptions": [
]
}
]
},
{

View File

@ -5,7 +5,6 @@
"css:config/http/notifications/base/handler.json",
"css:config/http/notifications/base/listener.json",
"css:config/http/notifications/base/storage.json",
"css:config/http/notifications/webhooks/description.json",
"css:config/http/notifications/webhooks/handler.json",
"css:config/http/notifications/webhooks/routes.json",
"css:config/http/notifications/webhooks/subscription.json"

View File

@ -1,17 +0,0 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
"@graph": [
{
"@id": "urn:solid-server:default:StorageDescriber",
"@type": "ArrayUnionHandler",
"handlers": [
{
"comment": "Handles the storage description triples used for discovery of a WebHookSubscription2021 endpoint.",
"@type": "WebHookDescriber",
"route": { "@id": "urn:solid-server:default:WebHookRoute" },
"webIdRoute": { "@id": "urn:solid-server:default:WebHookWebIdRoute" }
}
]
}
]
}

View File

@ -6,7 +6,7 @@
"@id": "urn:solid-server:default:WebHookSubscriber",
"@type": "OperationRouterHandler",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"allowedMethods": [ "POST" ],
"allowedMethods": [ "HEAD", "GET", "POST" ],
"allowedPathNames": [ "/WebHookSubscription2021/$" ],
"handler": {
"@type": "NotificationSubscriber",
@ -22,12 +22,24 @@
"comment": "Contains all the metadata relevant for a WebHookSubscription2021.",
"@id": "urn:solid-server:default:WebHookSubscription2021",
"@type": "WebHookSubscription2021",
"route": { "@id": "urn:solid-server:default:WebHookRoute" },
"webIdRoute": { "@id": "urn:solid-server:default:WebHookWebIdRoute" },
"unsubscribeRoute": { "@id": "urn:solid-server:default:WebHookUnsubscribeRoute" },
"stateHandler": {
"@type": "BaseStateHandler",
"handler": { "@id": "urn:solid-server:default:WebHookNotificationHandler" },
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" }
}
},
{
"@id": "urn:solid-server:default:NotificationDescriber",
"@type": "NotificationDescriber",
"subscriptions": [
{
"@id": "urn:solid-server:default:WebHookSubscription2021"
}
]
}
]
}

View File

@ -5,7 +5,6 @@
"css:config/http/notifications/base/handler.json",
"css:config/http/notifications/base/listener.json",
"css:config/http/notifications/base/storage.json",
"css:config/http/notifications/websockets/description.json",
"css:config/http/notifications/websockets/handler.json",
"css:config/http/notifications/websockets/http.json",
"css:config/http/notifications/websockets/subscription.json"

View File

@ -1,17 +0,0 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
"@graph": [
{
"@id": "urn:solid-server:default:StorageDescriber",
"@type": "ArrayUnionHandler",
"handlers": [
{
"comment": "Handles the storage description triples used for discovery of a WebSocketSubscription2021 endpoint.",
"@type": "NotificationDescriber",
"route": { "@id": "urn:solid-server:default:WebSocket2021Route" },
"type": "http://www.w3.org/ns/solid/notifications#WebSocketSubscription2021"
}
]
}
]
}

View File

@ -6,8 +6,8 @@
"@id": "urn:solid-server:default:WebSocket2021Subscriber",
"@type": "OperationRouterHandler",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"allowedMethods": [ "POST" ],
"allowedPathNames": [ "/WebSocketSubscription2021/" ],
"allowedMethods": [ "HEAD", "GET", "POST" ],
"allowedPathNames": [ "/WebSocketSubscription2021/$" ],
"handler": {
"@type": "NotificationSubscriber",
"channelType": { "@id": "urn:solid-server:default:WebSocketSubscription2021" },
@ -37,6 +37,16 @@
"handlers": [
{ "@id": "urn:solid-server:default:WebSocket2021Subscriber" }
]
},
{
"@id": "urn:solid-server:default:NotificationDescriber",
"@type": "NotificationDescriber",
"subscriptions": [
{
"@id": "urn:solid-server:default:WebSocketSubscription2021"
}
]
}
]
}

View File

@ -321,7 +321,6 @@ export * from './server/notifications/serialize/JsonLdNotificationSerializer';
export * from './server/notifications/serialize/NotificationSerializer';
// Server/Notifications/WebHookSubscription2021
export * from './server/notifications/WebHookSubscription2021/WebHookDescriber';
export * from './server/notifications/WebHookSubscription2021/WebHookEmitter';
export * from './server/notifications/WebHookSubscription2021/WebHookSubscription2021';
export * from './server/notifications/WebHookSubscription2021/WebHookUnsubscriber';
@ -355,6 +354,7 @@ export * from './server/notifications/TypedNotificationHandler';
// Server/Util
export * from './server/util/BaseRouterHandler';
export * from './server/util/ConvertingOperationHttpHandler';
export * from './server/util/OperationRouterHandler';
export * from './server/util/RedirectingHttpHandler';
export * from './server/util/RouterHandler';

View File

@ -1,7 +1,6 @@
import { OkResponseDescription } from '../../http/output/response/OkResponseDescription';
import type { ResponseDescription } from '../../http/output/response/ResponseDescription';
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter';
import type { ResourceStore } from '../../storage/ResourceStore';
import { INTERNAL_QUADS } from '../../util/ContentTypes';
import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError';
@ -20,15 +19,12 @@ import type { StorageDescriber } from './StorageDescriber';
export class StorageDescriptionHandler extends OperationHttpHandler {
private readonly store: ResourceStore;
private readonly path: string;
private readonly converter: RepresentationConverter;
private readonly describer: StorageDescriber;
public constructor(store: ResourceStore, path: string, converter: RepresentationConverter,
describer: StorageDescriber) {
public constructor(store: ResourceStore, path: string, describer: StorageDescriber) {
super();
this.store = store;
this.path = path;
this.converter = converter;
this.describer = describer;
}
@ -46,13 +42,11 @@ export class StorageDescriptionHandler extends OperationHttpHandler {
await this.describer.canHandle(target);
}
public async handle({ operation: { target, preferences }}: OperationHttpHandlerInput): Promise<ResponseDescription> {
public async handle({ operation: { target }}: OperationHttpHandlerInput): Promise<ResponseDescription> {
const quads = await this.describer.handle(target);
const representation = new BasicRepresentation(quads, INTERNAL_QUADS);
const converted = await this.converter.handleSafe({ identifier: target, representation, preferences });
return new OkResponseDescription(converted.metadata, converted.data);
return new OkResponseDescription(representation.metadata, representation.data);
}
}

View File

@ -1,6 +1,7 @@
import { Readable } from 'stream';
import { KeysRdfParseJsonLd } from '@comunica/context-entries';
import { parse, toSeconds } from 'iso8601-duration';
import { DataFactory } from 'n3';
import type { Store } from 'n3';
import type { NamedNode, Term } from 'rdf-js';
import rdfParser from 'rdf-parse';
@ -9,6 +10,7 @@ import { v4 } from 'uuid';
import type { Credentials } from '../../authentication/Credentials';
import type { AccessMap } from '../../authorization/permissions/Permissions';
import { AccessMode } from '../../authorization/permissions/Permissions';
import type { InteractionRoute } from '../../identity/interaction/routing/InteractionRoute';
import { ContextDocumentLoader } from '../../storage/conversion/ConversionUtil';
import { UnprocessableEntityHttpError } from '../../util/errors/UnprocessableEntityHttpError';
import { IdentifierSetMultiMap } from '../../util/map/IdentifierMap';
@ -17,8 +19,8 @@ import { msToDuration } from '../../util/StringUtil';
import { NOTIFY, RDF, XSD } from '../../util/Vocabularies';
import { CONTEXT_NOTIFICATION } from './Notification';
import type { NotificationChannel } from './NotificationChannel';
import type { NotificationChannelType } from './NotificationChannelType';
import { DEFAULT_NOTIFICATION_FEATURES } from './NotificationDescriber';
import type { NotificationChannelType, SubscriptionService } from './NotificationChannelType';
import namedNode = DataFactory.namedNode;
/**
* Helper type used to store information about the default features.
@ -40,6 +42,11 @@ const featureDefinitions: Feature[] = [
{ predicate: NOTIFY.terms.state, key: 'state', dataType: XSD.string },
];
/**
* The default notification features that are available on all channel types.
*/
export const DEFAULT_NOTIFICATION_FEATURES = featureDefinitions.map((feat): string => feat.predicate.value);
// This context is slightly outdated but seems to be the only "official" source for a SHACL context.
const CONTEXT_SHACL = 'https://w3c.github.io/shacl/shacl-jsonld-context/shacl.context.ld.json';
/**
@ -70,16 +77,30 @@ export const DEFAULT_SUBSCRIPTION_SHACL = {
*/
export abstract class BaseChannelType implements NotificationChannelType {
protected readonly type: NamedNode;
protected readonly path: string;
protected readonly shacl: unknown;
protected shaclQuads?: Store;
protected readonly features: NamedNode[];
/**
* @param type - The URI of the notification channel type.
* This will be added to the SHACL shape to validate incoming subscription data.
* @param route - The route corresponding to the URL of the subscription service of this channel type.
* Channel identifiers will be generated by appending a value to this URL.
* @param features - The features that should be enabled for this channel type.
* Values are expected to be full URIs, but the `notify:` prefix can also be used.
* @param additionalShaclProperties - Any additional properties that need to be added to the default SHACL shape.
*/
protected constructor(type: NamedNode, additionalShaclProperties: unknown[] = []) {
protected constructor(type: NamedNode, route: InteractionRoute,
features: string[] = DEFAULT_NOTIFICATION_FEATURES, additionalShaclProperties: unknown[] = []) {
this.type = type;
this.path = route.getPath();
this.features = features.map((feature): NamedNode => {
if (feature.startsWith('notify:')) {
feature = `${NOTIFY.namespace}${feature.slice('notify:'.length)}`;
}
return namedNode(feature);
});
// Inject requested properties into default SHACL shape
this.shacl = {
@ -93,6 +114,26 @@ export abstract class BaseChannelType implements NotificationChannelType {
};
}
public getDescription(): SubscriptionService {
return {
'@context': [ CONTEXT_NOTIFICATION ],
id: this.path,
// At the time of writing, there is no base value for URIs in the notification context,
// so we use the full URI instead.
channelType: this.type.value,
// Shorten known features to make the resulting JSON more readable
feature: this.features.map((node): string => {
if (DEFAULT_NOTIFICATION_FEATURES.includes(node.value)) {
return node.value.slice(NOTIFY.namespace.length);
}
if (node.value.startsWith(NOTIFY.namespace)) {
return `notify:${node.value.slice(NOTIFY.namespace.length)}`;
}
return node.value;
}),
};
}
/**
* Initiates the channel by first calling {@link validateSubscription} followed by {@link quadsToChannel}.
* Subclasses can override either function safely to impact the result of the function.

View File

@ -1,8 +1,20 @@
import type { Store } from 'n3';
import type { Credentials } from '../../authentication/Credentials';
import type { AccessMap } from '../../authorization/permissions/Permissions';
import type { CONTEXT_NOTIFICATION } from './Notification';
import type { NotificationChannel } from './NotificationChannel';
/**
* A subscription service description as based on the specification data model
* https://solidproject.org/TR/2022/notifications-protocol-20221231#subscription-service-data-model
*/
export interface SubscriptionService {
'@context': [ typeof CONTEXT_NOTIFICATION ];
id: string;
channelType: string;
feature: string[];
}
/**
* A specific channel type as defined at
* https://solidproject.org/TR/2022/notifications-protocol-20221231#notification-channel-types.
@ -11,6 +23,11 @@ import type { NotificationChannel } from './NotificationChannel';
* only need to support channels generated by an `initChannel` on the same class.
*/
export interface NotificationChannelType {
/**
* Returns the {@link SubscriptionService} that describes how to subscribe to this channel type.
*/
getDescription: () => SubscriptionService;
/**
* Validate and convert the input quads into a {@link NotificationChannel}.
* @param data - The input quads.

View File

@ -1,49 +1,50 @@
import type { NamedNode, Quad } from '@rdfjs/types';
import type { Quad } from '@rdfjs/types';
import arrayifyStream from 'arrayify-stream';
import { DataFactory } from 'n3';
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import type { InteractionRoute } from '../../identity/interaction/routing/InteractionRoute';
import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter';
import { APPLICATION_LD_JSON, INTERNAL_QUADS } from '../../util/ContentTypes';
import { NOTIFY } from '../../util/Vocabularies';
import { StorageDescriber } from '../description/StorageDescriber';
import type { NotificationChannelType } from './NotificationChannelType';
const { namedNode, quad } = DataFactory;
export const DEFAULT_NOTIFICATION_FEATURES = [
NOTIFY.accept,
NOTIFY.endAt,
NOTIFY.rate,
NOTIFY.startAt,
NOTIFY.state,
];
/**
* Outputs quads describing a Notification Subscription Service,
* Outputs quads describing all the subscription services of the server,
* as described in https://solidproject.org/TR/2022/notifications-protocol-20221231#discovery and
* https://solidproject.org/TR/2022/notifications-protocol-20221231#description-resource-data-model.
*
* In the future, if there is ever a need to add notification channels to the description resource as described above,
* this functionality should probably be added here as well.
*/
export class NotificationDescriber extends StorageDescriber {
private readonly path: NamedNode;
private readonly type: NamedNode;
private readonly features: NamedNode[];
private readonly converter: RepresentationConverter;
private readonly subscriptions: NotificationChannelType[];
/**
* @param route - The route describing where the subscription target is.
* @param type - The rdf:type of the subscription type.
* @param features - Which features are enabled for this subscription type. Defaults to accept/expiration/rate/state.
*/
public constructor(route: InteractionRoute, type: string,
features: string[] = DEFAULT_NOTIFICATION_FEATURES) {
public constructor(converter: RepresentationConverter, subscriptions: NotificationChannelType[]) {
super();
this.path = namedNode(route.getPath());
this.type = namedNode(type);
this.features = features.map(namedNode);
this.converter = converter;
this.subscriptions = subscriptions;
}
public async handle(input: ResourceIdentifier): Promise<Quad[]> {
const subject = namedNode(input.path);
public async handle(identifier: ResourceIdentifier): Promise<Quad[]> {
const subject = namedNode(identifier.path);
const subscriptionLinks: Quad[] = [];
const preferences = { type: { [INTERNAL_QUADS]: 1 }};
const subscriptionQuads = await Promise.all(this.subscriptions.map(async(sub): Promise<Quad[]> => {
const jsonld = sub.getDescription();
const representation = new BasicRepresentation(JSON.stringify(jsonld), { path: jsonld.id }, APPLICATION_LD_JSON);
const converted = await this.converter.handleSafe({ identifier, representation, preferences });
const arr = await arrayifyStream<Quad>(converted.data);
subscriptionLinks.push(quad(subject, NOTIFY.terms.subscription, namedNode(jsonld.id)));
return arr;
}));
return [
quad(subject, NOTIFY.terms.subscription, this.path),
quad(this.path, NOTIFY.terms.channelType, this.type),
...this.features.map((feature): Quad => quad(this.path, NOTIFY.terms.feature, feature)),
...subscriptionLinks,
...subscriptionQuads.flat(),
];
}
}

View File

@ -80,6 +80,13 @@ export class NotificationSubscriber extends OperationHttpHandler {
}
public async handle({ operation, request }: OperationHttpHandlerInput): Promise<ResponseDescription> {
if (operation.method === 'GET' || operation.method === 'HEAD') {
const description = JSON.stringify(this.channelType.getDescription(), null, 2);
const representation = new BasicRepresentation(description, operation.target, APPLICATION_LD_JSON);
return new OkResponseDescription(representation.metadata,
operation.method === 'GET' ? representation.data : undefined);
}
const credentials = await this.credentialsExtractor.handleSafe(request);
this.logger.debug(`Extracted credentials: ${JSON.stringify(credentials)}`);

View File

@ -1,42 +0,0 @@
import type { NamedNode } from '@rdfjs/types';
import { DataFactory } from 'n3';
import type { Quad } from 'rdf-js';
import type { ResourceIdentifier } from '../../../http/representation/ResourceIdentifier';
import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute';
import { NOTIFY } from '../../../util/Vocabularies';
import { DEFAULT_NOTIFICATION_FEATURES, NotificationDescriber } from '../NotificationDescriber';
const { namedNode, quad } = DataFactory;
export interface WebHookStorageDescriberArgs {
route: InteractionRoute;
webIdRoute: InteractionRoute;
features?: string[];
}
/**
* Handles the necessary triples for describing a WebHookSubscription2021 subscription service.
*
* Extends {@link NotificationDescriber} by adding the necessary `notify:webid` and `notify:webhookAuth` triples.
*/
export class WebHookDescriber extends NotificationDescriber {
private readonly webId: NamedNode;
public constructor(args: WebHookStorageDescriberArgs) {
const features = args.features ?? [ ...DEFAULT_NOTIFICATION_FEATURES ];
features.push(NOTIFY.webhookAuth);
super(args.route, NOTIFY.WebHookSubscription2021, features);
this.webId = namedNode(args.webIdRoute.getPath());
}
public async handle(input: ResourceIdentifier): Promise<Quad[]> {
const quads = await super.handle(input);
// Find the notification channel subject
const typeQuad = quads.find((entry): boolean => entry.predicate.equals(NOTIFY.terms.channelType) &&
entry.object.equals(NOTIFY.terms.WebHookSubscription2021));
quads.push(quad(typeQuad!.subject, NOTIFY.terms.webid, this.webId));
return quads;
}
}

View File

@ -5,8 +5,9 @@ import { getLoggerFor } from '../../../logging/LogUtil';
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
import { createErrorMessage } from '../../../util/errors/ErrorUtil';
import { NOTIFY } from '../../../util/Vocabularies';
import { BaseChannelType } from '../BaseChannelType';
import { BaseChannelType, DEFAULT_NOTIFICATION_FEATURES } from '../BaseChannelType';
import type { NotificationChannel } from '../NotificationChannel';
import type { SubscriptionService } from '../NotificationChannelType';
import type { StateHandler } from '../StateHandler';
import { generateWebHookUnsubscribeUrl } from './WebHook2021Util';
@ -33,6 +34,14 @@ export interface WebHookSubscription2021Channel extends NotificationChannel {
unsubscribe_endpoint: string;
}
/**
* An extension of {@link SubscriptionService} adding the necessary `webid` field.
* This is currently not part of a context so the terms are added in full to make sure the resulting RDF is valid.
*/
export interface WebHookSubscriptionService extends SubscriptionService {
[NOTIFY.webid]: { id: string };
}
export function isWebHook2021Channel(channel: NotificationChannel): channel is WebHookSubscription2021Channel {
return channel.type === NOTIFY.WebHookSubscription2021;
}
@ -50,9 +59,20 @@ export class WebHookSubscription2021 extends BaseChannelType {
private readonly unsubscribePath: string;
private readonly stateHandler: StateHandler;
private readonly webId: string;
public constructor(unsubscribeRoute: InteractionRoute, stateHandler: StateHandler) {
/**
* @param route - The route corresponding to the URL of the subscription service of this channel type.
* @param webIdRoute - The route to the WebID that needs to be used when generating DPoP tokens for notifications.
* @param unsubscribeRoute - The route where the request needs to be sent to unsubscribe.
* @param stateHandler - The {@link StateHandler} that will be called after a successful subscription.
* @param features - The features that need to be enabled for this channel type.
*/
public constructor(route: InteractionRoute, webIdRoute: InteractionRoute, unsubscribeRoute: InteractionRoute,
stateHandler: StateHandler, features: string[] = DEFAULT_NOTIFICATION_FEATURES) {
super(NOTIFY.terms.WebHookSubscription2021,
route,
[ ...features, NOTIFY.webhookAuth ],
// Need to remember to remove `target` from the vocabulary again once this is updated to webhooks 2023,
// as it is not actually part of the vocabulary.
// Technically we should also require that this node is a named node,
@ -62,6 +82,16 @@ export class WebHookSubscription2021 extends BaseChannelType {
[{ path: NOTIFY.target, minCount: 1, maxCount: 1 }]);
this.unsubscribePath = unsubscribeRoute.getPath();
this.stateHandler = stateHandler;
this.webId = webIdRoute.getPath();
}
public getDescription(): WebHookSubscriptionService {
const base = super.getDescription();
return {
...base,
[NOTIFY.webid]: { id: this.webId },
};
}
public async initChannel(data: Store, credentials: Credentials): Promise<WebHookSubscription2021Channel> {

View File

@ -34,11 +34,8 @@ export function isWebSocket2021Channel(channel: NotificationChannel): channel is
export class WebSocketSubscription2021 extends BaseChannelType {
protected readonly logger = getLoggerFor(this);
private readonly path: string;
public constructor(route: InteractionRoute) {
super(NOTIFY.terms.WebSocketSubscription2021);
this.path = route.getPath();
public constructor(route: InteractionRoute, features?: string[]) {
super(NOTIFY.terms.WebSocketSubscription2021, route, features);
}
public async initChannel(data: Store, credentials: Credentials): Promise<WebSocketSubscription2021Channel> {

View File

@ -0,0 +1,48 @@
import type { ResponseDescription } from '../../http/output/response/ResponseDescription';
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter';
import { InternalServerError } from '../../util/errors/InternalServerError';
import type { OperationHttpHandlerInput } from '../OperationHttpHandler';
import { OperationHttpHandler } from '../OperationHttpHandler';
/**
* An {@link OperationHttpHandler} that converts the response of its handler based on the {@link Operation} preferences.
* If there are no preferences, or no data, the response will be returned as-is.
*/
export class ConvertingOperationHttpHandler extends OperationHttpHandler {
private readonly converter: RepresentationConverter;
private readonly operationHandler: OperationHttpHandler;
public constructor(converter: RepresentationConverter, operationHandler: OperationHttpHandler) {
super();
this.converter = converter;
this.operationHandler = operationHandler;
}
public async canHandle(input: OperationHttpHandlerInput): Promise<void> {
await this.operationHandler.canHandle(input);
}
public async handle(input: OperationHttpHandlerInput): Promise<ResponseDescription> {
const response = await this.operationHandler.handle(input);
if (input.operation.preferences.type && response.data) {
if (!response.metadata) {
throw new InternalServerError('A data stream should always have a metadata object.');
}
const representation = new BasicRepresentation(response.data, response.metadata);
const converted = await this.converter.handleSafe({
identifier: input.operation.target,
representation,
preferences: input.operation.preferences,
});
response.metadata = converted.metadata;
response.data = converted.data;
}
return response;
}
}

View File

@ -7,7 +7,6 @@ import type { StorageDescriber } from '../../../../src/server/description/Storag
import { StorageDescriptionHandler } from '../../../../src/server/description/StorageDescriptionHandler';
import type { HttpRequest } from '../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../src/server/HttpResponse';
import type { RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter';
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { readableToQuads } from '../../../../src/util/StreamUtil';
import { PIM, RDF } from '../../../../src/util/Vocabularies';
@ -21,7 +20,6 @@ describe('A StorageDescriptionHandler', (): void => {
let operation: Operation;
let representation: Representation;
let store: jest.Mocked<ResourceStore>;
let converter: jest.Mocked<RepresentationConverter>;
let describer: jest.Mocked<StorageDescriber>;
let handler: StorageDescriptionHandler;
@ -40,17 +38,13 @@ describe('A StorageDescriptionHandler', (): void => {
getRepresentation: jest.fn().mockResolvedValue(representation),
} as any;
converter = {
handleSafe: jest.fn(async({ representation: rep }): Promise<Representation> => rep),
} as any;
describer = {
canHandle: jest.fn(),
handle: jest.fn(async(target): Promise<Quad[]> =>
[ quad(namedNode(target.path), RDF.terms.type, PIM.terms.Storage) ]),
} as any;
handler = new StorageDescriptionHandler(store, path, converter, describer);
handler = new StorageDescriptionHandler(store, path, describer);
});
it('only handles GET requests.', async(): Promise<void> => {
@ -92,6 +86,5 @@ describe('A StorageDescriptionHandler', (): void => {
expect(quads.countQuads(operation.target.path, RDF.terms.type, PIM.terms.Storage, null)).toBe(1);
expect(describer.handle).toHaveBeenCalledTimes(1);
expect(describer.handle).toHaveBeenLastCalledWith(operation.target);
expect(converter.handleSafe).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,9 +1,11 @@
import { DataFactory, Store } from 'n3';
import type { Credentials } from '../../../../src/authentication/Credentials';
import { AccessMode } from '../../../../src/authorization/permissions/Permissions';
import { BaseChannelType } from '../../../../src/server/notifications/BaseChannelType';
import {
AbsolutePathInteractionRoute,
} from '../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
import { BaseChannelType, DEFAULT_NOTIFICATION_FEATURES } from '../../../../src/server/notifications/BaseChannelType';
import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel';
import { DEFAULT_NOTIFICATION_FEATURES } from '../../../../src/server/notifications/NotificationDescriber';
import { UnprocessableEntityHttpError } from '../../../../src/util/errors/UnprocessableEntityHttpError';
import { IdentifierSetMultiMap } from '../../../../src/util/map/IdentifierMap';
import { NOTIFY, RDF, XSD } from '../../../../src/util/Vocabularies';
@ -16,9 +18,11 @@ jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a5
const dummyType = namedNode('http://example.com/DummyType');
class DummyChannelType extends BaseChannelType {
public constructor(properties?: unknown[]) {
public constructor(features?: string[], properties?: unknown[]) {
super(
dummyType,
new AbsolutePathInteractionRoute('http://example.com/DummyType/'),
features,
properties,
);
}
@ -29,6 +33,38 @@ describe('A BaseChannelType', (): void => {
const credentials: Credentials = {};
const channelType = new DummyChannelType();
it('can provide a description of the subscription service.', async(): Promise<void> => {
expect(channelType.getDescription()).toEqual({
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
id: 'http://example.com/DummyType/',
channelType: dummyType.value,
feature: [ 'accept', 'endAt', 'rate', 'startAt', 'state' ],
});
});
it('can configure specific features.', async(): Promise<void> => {
const otherChannelType = new DummyChannelType([ 'notify:accept' ]);
expect(otherChannelType.getDescription()).toEqual({
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
id: 'http://example.com/DummyType/',
channelType: dummyType.value,
feature: [ 'accept' ],
});
});
it('uses the notify prefix for non-default features in the namespace.', async(): Promise<void> => {
const otherChannelType = new DummyChannelType([ `${NOTIFY.namespace}feat1`, 'http://example.com/feat2' ]);
expect(otherChannelType.getDescription()).toEqual({
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
id: 'http://example.com/DummyType/',
channelType: dummyType.value,
feature: [
'notify:feat1',
'http://example.com/feat2',
],
});
});
describe('#initChannel', (): void => {
let data: Store;
const subject = blankNode();

View File

@ -1,34 +1,56 @@
import 'jest-rdf';
import { DataFactory } from 'n3';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { Representation } from '../../../../src/http/representation/Representation';
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
import {
AbsolutePathInteractionRoute,
} from '../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
import type { NotificationChannelType } from '../../../../src/server/notifications/NotificationChannelType';
import { NotificationDescriber } from '../../../../src/server/notifications/NotificationDescriber';
import type {
RepresentationConverter,
RepresentationConverterArgs,
} from '../../../../src/storage/conversion/RepresentationConverter';
import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes';
import { readableToString } from '../../../../src/util/StreamUtil';
import { NOTIFY } from '../../../../src/util/Vocabularies';
const { namedNode, quad } = DataFactory;
describe('A NotificationDescriber', (): void => {
const identifier: ResourceIdentifier = { path: 'http://example.com/foo' };
const route = new AbsolutePathInteractionRoute('http://example.com/.notifications/websockets/');
const type = 'http://www.w3.org/ns/solid/notifications#WebSocketSubscription2021';
const identifier: ResourceIdentifier = { path: 'http://example.com/solid/' };
const jsonld1 = { id: 'http://example.com/.notifications/websockets/' };
const jsonld2 = { id: 'http://example.com/.notifications/extra/' };
let converter: jest.Mocked<RepresentationConverter>;
let subscription1: jest.Mocked<NotificationChannelType>;
let subscription2: jest.Mocked<NotificationChannelType>;
let describer: NotificationDescriber;
beforeEach(async(): Promise<void> => {
describer = new NotificationDescriber(route, type);
subscription1 = {
getDescription: jest.fn().mockReturnValue(jsonld1),
} as any;
subscription2 = {
getDescription: jest.fn().mockReturnValue(jsonld2),
} as any;
converter = {
handleSafe: jest.fn(async({ representation }: RepresentationConverterArgs): Promise<Representation> => {
const jsonld = JSON.parse(await readableToString(representation.data));
return new BasicRepresentation([
quad(namedNode(jsonld.id), NOTIFY.terms.feature, NOTIFY.terms.rate),
], INTERNAL_QUADS);
}),
} as any;
describer = new NotificationDescriber(converter, [ subscription1, subscription2 ]);
});
it('outputs the expected quads.', async(): Promise<void> => {
const subscription = namedNode('http://example.com/.notifications/websockets/');
const quads = await describer.handle(identifier);
expect(quads).toBeRdfIsomorphic([
quad(namedNode(identifier.path), NOTIFY.terms.subscription, subscription),
quad(subscription, NOTIFY.terms.channelType, namedNode(type)),
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.accept),
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.endAt),
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.startAt),
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.rate),
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.state),
it('converts the JSON-LD to quads.', async(): Promise<void> => {
await expect(describer.handle(identifier)).resolves.toBeRdfIsomorphic([
quad(namedNode(identifier.path), NOTIFY.terms.subscription, namedNode(jsonld1.id)),
quad(namedNode(identifier.path), NOTIFY.terms.subscription, namedNode(jsonld2.id)),
quad(namedNode(jsonld1.id), NOTIFY.terms.feature, NOTIFY.terms.rate),
quad(namedNode(jsonld2.id), NOTIFY.terms.feature, NOTIFY.terms.rate),
]);
});
});

View File

@ -12,7 +12,10 @@ import type { HttpRequest } from '../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../src/server/HttpResponse';
import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel';
import type { NotificationChannelStorage } from '../../../../src/server/notifications/NotificationChannelStorage';
import type { NotificationChannelType } from '../../../../src/server/notifications/NotificationChannelType';
import type {
NotificationChannelType,
SubscriptionService,
} from '../../../../src/server/notifications/NotificationChannelType';
import { NotificationSubscriber } from '../../../../src/server/notifications/NotificationSubscriber';
import type { RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter';
import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes';
@ -32,6 +35,12 @@ describe('A NotificationSubscriber', (): void => {
const response: HttpResponse = {} as any;
let operation: Operation;
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
const subscriptionService: SubscriptionService = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
id: 'http://example.com/subscription/',
channelType: 'DummyType',
feature: [ 'rate' ],
};
let channel: NotificationChannel;
let channelType: jest.Mocked<NotificationChannelType>;
let converter: jest.Mocked<RepresentationConverter>;
@ -56,6 +65,7 @@ describe('A NotificationSubscriber', (): void => {
};
channelType = {
getDescription: jest.fn().mockReturnValue(subscriptionService),
initChannel: jest.fn().mockResolvedValue(channel),
toJsonLd: jest.fn().mockResolvedValue({}),
extractModes: jest.fn(async(subscription): Promise<AccessMap> =>
@ -88,6 +98,22 @@ describe('A NotificationSubscriber', (): void => {
);
});
it('returns a subscription service description on GET requests.', async(): Promise<void> => {
operation.method = 'GET';
const description = await subscriber.handle({ operation, request, response });
expect(description.statusCode).toBe(200);
expect(description.metadata?.contentType).toBe('application/ld+json');
expect(JSON.parse(await readableToString(description.data!))).toEqual(subscriptionService);
});
it('only returns metadata on HEAD requests.', async(): Promise<void> => {
operation.method = 'HEAD';
const description = await subscriber.handle({ operation, request, response });
expect(description.statusCode).toBe(200);
expect(description.metadata?.contentType).toBe('application/ld+json');
expect(description.data).toBeUndefined();
});
it('errors if the request can not be parsed correctly.', async(): Promise<void> => {
converter.handleSafe.mockRejectedValueOnce(new Error('bad data'));
await expect(subscriber.handle({ operation, request, response })).rejects.toThrow('bad data');

View File

@ -1,37 +0,0 @@
import 'jest-rdf';
import { DataFactory } from 'n3';
import type { ResourceIdentifier } from '../../../../../src/http/representation/ResourceIdentifier';
import {
AbsolutePathInteractionRoute,
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
import { WebHookDescriber } from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookDescriber';
import { NOTIFY } from '../../../../../src/util/Vocabularies';
const { namedNode, quad } = DataFactory;
describe('A WebHookDescriber', (): void => {
const identifier: ResourceIdentifier = { path: 'http://example.com/foo' };
const route = new AbsolutePathInteractionRoute('http://example.com/.notifications/webhooks/');
const webIdRoute = new AbsolutePathInteractionRoute('http://example.com/.notifications/webhooks/webId');
const type = 'http://www.w3.org/ns/solid/notifications#WebHookSubscription2021';
let describer: WebHookDescriber;
beforeEach(async(): Promise<void> => {
describer = new WebHookDescriber({ route, webIdRoute });
});
it('outputs the expected quads.', async(): Promise<void> => {
const subscription = namedNode('http://example.com/.notifications/webhooks/');
const quads = await describer.handle(identifier);
expect(quads).toBeRdfIsomorphic([
quad(namedNode(identifier.path), NOTIFY.terms.subscription, subscription),
quad(subscription, NOTIFY.terms.channelType, namedNode(type)),
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.accept),
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.endAt),
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.startAt),
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.rate),
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.state),
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.webhookAuth),
quad(subscription, NOTIFY.terms.webid, namedNode(webIdRoute.getPath())),
]);
});
});

View File

@ -3,6 +3,9 @@ import type { Credentials } from '../../../../../src/authentication/Credentials'
import {
AbsolutePathInteractionRoute,
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
import {
RelativePathInteractionRoute,
} from '../../../../../src/identity/interaction/routing/RelativePathInteractionRoute';
import type { Logger } from '../../../../../src/logging/Logger';
import { getLoggerFor } from '../../../../../src/logging/LogUtil';
import { CONTEXT_NOTIFICATION } from '../../../../../src/server/notifications/Notification';
@ -36,7 +39,9 @@ describe('A WebHookSubscription2021', (): void => {
const subject = blankNode();
let data: Store;
let channel: WebHookSubscription2021Channel;
const unsubscribeRoute = new AbsolutePathInteractionRoute('http://example.com/unsubscribe');
const route = new AbsolutePathInteractionRoute('http://example.com/webhooks/');
const webIdRoute = new RelativePathInteractionRoute(route, '/webid');
const unsubscribeRoute = new RelativePathInteractionRoute(route, '/unsubscribe');
let stateHandler: jest.Mocked<StateHandler>;
let channelType: WebHookSubscription2021;
@ -61,7 +66,7 @@ describe('A WebHookSubscription2021', (): void => {
handleSafe: jest.fn(),
} as any;
channelType = new WebHookSubscription2021(unsubscribeRoute, stateHandler);
channelType = new WebHookSubscription2021(route, webIdRoute, unsubscribeRoute, stateHandler);
});
it('exposes a utility function to verify if a channel is a webhook channel.', async(): Promise<void> => {
@ -71,6 +76,16 @@ describe('A WebHookSubscription2021', (): void => {
expect(isWebHook2021Channel(channel)).toBe(false);
});
it('returns a correct description of the subscription service.', async(): Promise<void> => {
expect(channelType.getDescription()).toEqual({
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
id: 'http://example.com/webhooks/',
channelType: 'http://www.w3.org/ns/solid/notifications#WebHookSubscription2021',
feature: [ 'accept', 'endAt', 'rate', 'startAt', 'state', 'notify:webhookAuth' ],
'http://www.w3.org/ns/solid/notifications#webid': { id: 'http://example.com/webhooks/webid' },
});
});
it('correctly parses notification channel bodies.', async(): Promise<void> => {
await expect(channelType.initChannel(data, credentials)).resolves.toEqual(channel);
});

View File

@ -0,0 +1,85 @@
import type { Operation } from '../../../../src/http/Operation';
import { OkResponseDescription } from '../../../../src/http/output/response/OkResponseDescription';
import { ResponseDescription } from '../../../../src/http/output/response/ResponseDescription';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { HttpRequest } from '../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../src/server/HttpResponse';
import type { OperationHttpHandler } from '../../../../src/server/OperationHttpHandler';
import { ConvertingOperationHttpHandler } from '../../../../src/server/util/ConvertingOperationHttpHandler';
import type {
RepresentationConverter,
} from '../../../../src/storage/conversion/RepresentationConverter';
import { InternalServerError } from '../../../../src/util/errors/InternalServerError';
import { guardedStreamFrom } from '../../../../src/util/StreamUtil';
describe('A ConvertingOperationHttpHandler', (): void => {
const request: HttpRequest = {} as HttpRequest;
const response: HttpResponse = {} as HttpResponse;
let operation: Operation;
const representation = new BasicRepresentation([], 'application/ld+json');
let handlerResponse: ResponseDescription;
const converted = new BasicRepresentation([], 'text/turtle');
let converter: jest.Mocked<RepresentationConverter>;
let operationHandler: jest.Mocked<OperationHttpHandler>;
let handler: ConvertingOperationHttpHandler;
beforeEach(async(): Promise<void> => {
handlerResponse = new OkResponseDescription(representation.metadata, representation.data);
operation = {
method: 'GET',
target: { path: 'http://example.com/foo' },
body: new BasicRepresentation(),
preferences: { type: { 'text/turtle': 1 }},
};
converter = {
handleSafe: jest.fn().mockResolvedValue(converted),
} as any;
operationHandler = {
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue(handlerResponse),
} as any;
handler = new ConvertingOperationHttpHandler(converter, operationHandler);
});
it('can handle input if its handler can handle it.', async(): Promise<void> => {
await expect(handler.canHandle({ request, response, operation })).resolves.toBeUndefined();
operationHandler.canHandle.mockRejectedValueOnce(new Error('bad data'));
await expect(handler.canHandle({ request, response, operation })).rejects.toThrow('bad data');
});
it('does not convert if there are no type preferences.', async(): Promise<void> => {
delete operation.preferences.type;
await expect(handler.handle({ request, response, operation })).resolves.toBe(handlerResponse);
expect(converter.handleSafe).toHaveBeenCalledTimes(0);
});
it('does not convert if there is no output data.', async(): Promise<void> => {
const emptyResponse = new ResponseDescription(200);
operationHandler.handle.mockResolvedValueOnce(emptyResponse);
await expect(handler.handle({ request, response, operation })).resolves.toBe(emptyResponse);
expect(converter.handleSafe).toHaveBeenCalledTimes(0);
});
it('converts the response if requested.', async(): Promise<void> => {
const result = await handler.handle({ request, response, operation });
expect(result.data).toBe(converted.data);
expect(result.metadata).toBe(converted.metadata);
expect(result.statusCode).toBe(handlerResponse.statusCode);
expect(converter.handleSafe).toHaveBeenCalledTimes(1);
expect(converter.handleSafe).toHaveBeenLastCalledWith({
identifier: operation.target,
representation,
preferences: operation.preferences,
});
});
it('errors if there is data without metadata.', async(): Promise<void> => {
operationHandler.handle.mockResolvedValueOnce(new ResponseDescription(200, undefined, guardedStreamFrom('')));
await expect(handler.handle({ request, response, operation })).rejects.toThrow(InternalServerError);
});
});