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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user