From 65860f77da56dfc80f68ea6a43b99ac3b3e202a5 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 31 Jan 2023 11:18:35 +0100 Subject: [PATCH] feat: Support GET requests on subscription services Doing a GET request on a subscription resource will return the expected representation. Content negotiation is supported. --- .../http/handler/handlers/notifications.json | 13 ++- .../handler/handlers/storage-description.json | 12 ++- config/http/notifications/all.json | 2 - .../http/notifications/base/description.json | 13 ++- config/http/notifications/webhooks.json | 1 - .../notifications/webhooks/description.json | 17 ---- .../notifications/webhooks/subscription.json | 14 ++- config/http/notifications/websockets.json | 1 - .../notifications/websockets/description.json | 17 ---- .../websockets/subscription.json | 14 ++- src/index.ts | 2 +- .../description/StorageDescriptionHandler.ts | 12 +-- src/server/notifications/BaseChannelType.ts | 47 +++++++++- .../notifications/NotificationChannelType.ts | 17 ++++ .../notifications/NotificationDescriber.ts | 59 ++++++------- .../notifications/NotificationSubscriber.ts | 7 ++ .../WebHookDescriber.ts | 42 --------- .../WebHookSubscription2021.ts | 34 +++++++- .../WebSocketSubscription2021.ts | 7 +- .../util/ConvertingOperationHttpHandler.ts | 48 +++++++++++ .../StorageDescriptionHandler.test.ts | 9 +- .../notifications/BaseChannelType.test.ts | 42 ++++++++- .../NotificationDescriber.test.ts | 58 +++++++++---- .../NotificationSubscriber.test.ts | 28 +++++- .../WebHookDescriber.test.ts | 37 -------- .../WebHookSubscription2021.test.ts | 19 ++++- .../ConvertingOperationHttpHandler.test.ts | 85 +++++++++++++++++++ 27 files changed, 446 insertions(+), 211 deletions(-) delete mode 100644 config/http/notifications/webhooks/description.json delete mode 100644 config/http/notifications/websockets/description.json delete mode 100644 src/server/notifications/WebHookSubscription2021/WebHookDescriber.ts create mode 100644 src/server/util/ConvertingOperationHttpHandler.ts delete mode 100644 test/unit/server/notifications/WebHookSubscription2021/WebHookDescriber.test.ts create mode 100644 test/unit/server/util/ConvertingOperationHttpHandler.test.ts diff --git a/config/http/handler/handlers/notifications.json b/config/http/handler/handlers/notifications.json index dbeb51dea..2d33b5d9e 100644 --- a/config/http/handler/handlers/notifications.json +++ b/config/http/handler/handlers/notifications.json @@ -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": [ ] + } } } ] diff --git a/config/http/handler/handlers/storage-description.json b/config/http/handler/handlers/storage-description.json index dea775818..a60f3a02c 100644 --- a/config/http/handler/handlers/storage-description.json +++ b/config/http/handler/handlers/storage-description.json @@ -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" } + } } } }, diff --git a/config/http/notifications/all.json b/config/http/notifications/all.json index 287f896b3..f8e2cf1ff 100644 --- a/config/http/notifications/all.json +++ b/config/http/notifications/all.json @@ -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" diff --git a/config/http/notifications/base/description.json b/config/http/notifications/base/description.json index ce384dbb8..640c414cf 100644 --- a/config/http/notifications/base/description.json +++ b/config/http/notifications/base/description.json @@ -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": [ + + ] + } + ] }, { diff --git a/config/http/notifications/webhooks.json b/config/http/notifications/webhooks.json index 44365b573..23d374acc 100644 --- a/config/http/notifications/webhooks.json +++ b/config/http/notifications/webhooks.json @@ -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" diff --git a/config/http/notifications/webhooks/description.json b/config/http/notifications/webhooks/description.json deleted file mode 100644 index 4f015b548..000000000 --- a/config/http/notifications/webhooks/description.json +++ /dev/null @@ -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" } - } - ] - } - ] -} diff --git a/config/http/notifications/webhooks/subscription.json b/config/http/notifications/webhooks/subscription.json index 9ef66f530..dd0f22bf2 100644 --- a/config/http/notifications/webhooks/subscription.json +++ b/config/http/notifications/webhooks/subscription.json @@ -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" + } + ] } ] } diff --git a/config/http/notifications/websockets.json b/config/http/notifications/websockets.json index a5879ba00..8c8cfce93 100644 --- a/config/http/notifications/websockets.json +++ b/config/http/notifications/websockets.json @@ -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" diff --git a/config/http/notifications/websockets/description.json b/config/http/notifications/websockets/description.json deleted file mode 100644 index b9666ed13..000000000 --- a/config/http/notifications/websockets/description.json +++ /dev/null @@ -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" - } - ] - } - ] -} diff --git a/config/http/notifications/websockets/subscription.json b/config/http/notifications/websockets/subscription.json index c98eb2003..57afa9fff 100644 --- a/config/http/notifications/websockets/subscription.json +++ b/config/http/notifications/websockets/subscription.json @@ -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" + } + ] } ] } diff --git a/src/index.ts b/src/index.ts index ba3bf7ec6..ebc0643c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/server/description/StorageDescriptionHandler.ts b/src/server/description/StorageDescriptionHandler.ts index bfb4cc9b1..27169d658 100644 --- a/src/server/description/StorageDescriptionHandler.ts +++ b/src/server/description/StorageDescriptionHandler.ts @@ -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 { + public async handle({ operation: { target }}: OperationHttpHandlerInput): Promise { 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); } } diff --git a/src/server/notifications/BaseChannelType.ts b/src/server/notifications/BaseChannelType.ts index 618592014..4b4fc61e3 100644 --- a/src/server/notifications/BaseChannelType.ts +++ b/src/server/notifications/BaseChannelType.ts @@ -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. diff --git a/src/server/notifications/NotificationChannelType.ts b/src/server/notifications/NotificationChannelType.ts index ac7900569..642d55dd0 100644 --- a/src/server/notifications/NotificationChannelType.ts +++ b/src/server/notifications/NotificationChannelType.ts @@ -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. diff --git a/src/server/notifications/NotificationDescriber.ts b/src/server/notifications/NotificationDescriber.ts index 9506341d6..383b94071 100644 --- a/src/server/notifications/NotificationDescriber.ts +++ b/src/server/notifications/NotificationDescriber.ts @@ -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 { - const subject = namedNode(input.path); + public async handle(identifier: ResourceIdentifier): Promise { + 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 => { + 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(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(), ]; } } diff --git a/src/server/notifications/NotificationSubscriber.ts b/src/server/notifications/NotificationSubscriber.ts index 1afd5aa1a..152c42274 100644 --- a/src/server/notifications/NotificationSubscriber.ts +++ b/src/server/notifications/NotificationSubscriber.ts @@ -80,6 +80,13 @@ export class NotificationSubscriber extends OperationHttpHandler { } public async handle({ operation, request }: OperationHttpHandlerInput): Promise { + 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)}`); diff --git a/src/server/notifications/WebHookSubscription2021/WebHookDescriber.ts b/src/server/notifications/WebHookSubscription2021/WebHookDescriber.ts deleted file mode 100644 index ee19d9688..000000000 --- a/src/server/notifications/WebHookSubscription2021/WebHookDescriber.ts +++ /dev/null @@ -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 { - 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; - } -} diff --git a/src/server/notifications/WebHookSubscription2021/WebHookSubscription2021.ts b/src/server/notifications/WebHookSubscription2021/WebHookSubscription2021.ts index 30b09b9ce..e0d29562d 100644 --- a/src/server/notifications/WebHookSubscription2021/WebHookSubscription2021.ts +++ b/src/server/notifications/WebHookSubscription2021/WebHookSubscription2021.ts @@ -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 { diff --git a/src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021.ts b/src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021.ts index ee3d9a85b..af5427197 100644 --- a/src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021.ts +++ b/src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021.ts @@ -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 { diff --git a/src/server/util/ConvertingOperationHttpHandler.ts b/src/server/util/ConvertingOperationHttpHandler.ts new file mode 100644 index 000000000..ab8a4d59a --- /dev/null +++ b/src/server/util/ConvertingOperationHttpHandler.ts @@ -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 { + await this.operationHandler.canHandle(input); + } + + public async handle(input: OperationHttpHandlerInput): Promise { + 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; + } +} diff --git a/test/unit/server/description/StorageDescriptionHandler.test.ts b/test/unit/server/description/StorageDescriptionHandler.test.ts index 16cf450b5..54f1409e0 100644 --- a/test/unit/server/description/StorageDescriptionHandler.test.ts +++ b/test/unit/server/description/StorageDescriptionHandler.test.ts @@ -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; - let converter: jest.Mocked; let describer: jest.Mocked; 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 => rep), - } as any; - describer = { canHandle: jest.fn(), handle: jest.fn(async(target): Promise => [ 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 => { @@ -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); }); }); diff --git a/test/unit/server/notifications/BaseChannelType.test.ts b/test/unit/server/notifications/BaseChannelType.test.ts index 657cfdc99..a8f1e6d2d 100644 --- a/test/unit/server/notifications/BaseChannelType.test.ts +++ b/test/unit/server/notifications/BaseChannelType.test.ts @@ -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 => { + 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 => { + 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 => { + 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(); diff --git a/test/unit/server/notifications/NotificationDescriber.test.ts b/test/unit/server/notifications/NotificationDescriber.test.ts index c9f62cffc..441fa3066 100644 --- a/test/unit/server/notifications/NotificationDescriber.test.ts +++ b/test/unit/server/notifications/NotificationDescriber.test.ts @@ -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; + let subscription1: jest.Mocked; + let subscription2: jest.Mocked; let describer: NotificationDescriber; beforeEach(async(): Promise => { - 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 => { + 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 => { - 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 => { + 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), ]); }); }); diff --git a/test/unit/server/notifications/NotificationSubscriber.test.ts b/test/unit/server/notifications/NotificationSubscriber.test.ts index 62b47c229..864b8db88 100644 --- a/test/unit/server/notifications/NotificationSubscriber.test.ts +++ b/test/unit/server/notifications/NotificationSubscriber.test.ts @@ -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; let converter: jest.Mocked; @@ -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 => @@ -88,6 +98,22 @@ describe('A NotificationSubscriber', (): void => { ); }); + it('returns a subscription service description on GET requests.', async(): Promise => { + 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 => { + 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 => { converter.handleSafe.mockRejectedValueOnce(new Error('bad data')); await expect(subscriber.handle({ operation, request, response })).rejects.toThrow('bad data'); diff --git a/test/unit/server/notifications/WebHookSubscription2021/WebHookDescriber.test.ts b/test/unit/server/notifications/WebHookSubscription2021/WebHookDescriber.test.ts deleted file mode 100644 index b44028734..000000000 --- a/test/unit/server/notifications/WebHookSubscription2021/WebHookDescriber.test.ts +++ /dev/null @@ -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 => { - describer = new WebHookDescriber({ route, webIdRoute }); - }); - - it('outputs the expected quads.', async(): Promise => { - 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())), - ]); - }); -}); diff --git a/test/unit/server/notifications/WebHookSubscription2021/WebHookSubscription2021.test.ts b/test/unit/server/notifications/WebHookSubscription2021/WebHookSubscription2021.test.ts index 2d9e7a785..59e0486ab 100644 --- a/test/unit/server/notifications/WebHookSubscription2021/WebHookSubscription2021.test.ts +++ b/test/unit/server/notifications/WebHookSubscription2021/WebHookSubscription2021.test.ts @@ -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; 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 => { @@ -71,6 +76,16 @@ describe('A WebHookSubscription2021', (): void => { expect(isWebHook2021Channel(channel)).toBe(false); }); + it('returns a correct description of the subscription service.', async(): Promise => { + 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 => { await expect(channelType.initChannel(data, credentials)).resolves.toEqual(channel); }); diff --git a/test/unit/server/util/ConvertingOperationHttpHandler.test.ts b/test/unit/server/util/ConvertingOperationHttpHandler.test.ts new file mode 100644 index 000000000..2571efe06 --- /dev/null +++ b/test/unit/server/util/ConvertingOperationHttpHandler.test.ts @@ -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; + let operationHandler: jest.Mocked; + let handler: ConvertingOperationHttpHandler; + + beforeEach(async(): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + operationHandler.handle.mockResolvedValueOnce(new ResponseDescription(200, undefined, guardedStreamFrom(''))); + await expect(handler.handle({ request, response, operation })).rejects.toThrow(InternalServerError); + }); +});