mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
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:
parent
b2f4d7fb2d
commit
65860f77da
@ -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": [ ]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -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"
|
||||
|
@ -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": [
|
||||
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
|
@ -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"
|
||||
|
@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -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)}`);
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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> {
|
||||
|
@ -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> {
|
||||
|
48
src/server/util/ConvertingOperationHttpHandler.ts
Normal file
48
src/server/util/ConvertingOperationHttpHandler.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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();
|
||||
|
@ -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),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -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');
|
||||
|
@ -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())),
|
||||
]);
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
|
85
test/unit/server/util/ConvertingOperationHttpHandler.test.ts
Normal file
85
test/unit/server/util/ConvertingOperationHttpHandler.test.ts
Normal 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);
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user