feat: Support GET requests on subscription services

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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