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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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