feat: Pass ResourceStore as param to PatchHandler

This way the chain of ResourceStores is a bit easier to configure.
This commit also updates the SparqlUpdatePatchHandler to keep the metadata
and content-type of the resource that is being modified.
This commit is contained in:
Joachim Van Herwegen 2021-05-07 10:31:21 +02:00
parent 0f22fb5ddb
commit 8491300f42
6 changed files with 139 additions and 45 deletions

View File

@ -42,8 +42,8 @@
"patcher": { "patcher": {
"@id": "urn:solid-server:default:PatchHandler", "@id": "urn:solid-server:default:PatchHandler",
"@type": "SparqlUpdatePatchHandler", "@type": "SparqlUpdatePatchHandler",
"source": { "converter": {
"@id": "urn:solid-server:default:ResourceStore_ToTurtle" "@id": "urn:solid-server:default:RepresentationConverter"
} }
} }
}, },

View File

@ -23,7 +23,7 @@ export class PatchingStore<T extends ResourceStore = ResourceStore> extends Pass
try { try {
return await this.source.modifyResource(identifier, patch, conditions); return await this.source.modifyResource(identifier, patch, conditions);
} catch { } catch {
return this.patcher.handleSafe({ identifier, patch }); return this.patcher.handleSafe({ source: this.source, identifier, patch });
} }
} }
} }

View File

@ -1,6 +1,16 @@
import type { Patch } from '../../ldp/http/Patch'; import type { Patch } from '../../ldp/http/Patch';
import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
import { AsyncHandler } from '../../util/handlers/AsyncHandler'; import { AsyncHandler } from '../../util/handlers/AsyncHandler';
import type { ResourceStore } from '../ResourceStore';
export abstract class PatchHandler export type PatchHandlerArgs<T extends ResourceStore = ResourceStore> = {
extends AsyncHandler<{identifier: ResourceIdentifier; patch: Patch}, ResourceIdentifier[]> {} source: T;
identifier: ResourceIdentifier;
patch: Patch;
};
/**
* Executes the given Patch.
*/
export abstract class PatchHandler<T extends ResourceStore = ResourceStore>
extends AsyncHandler<PatchHandlerArgs<T>, ResourceIdentifier[]> {}

View File

@ -4,44 +4,56 @@ import { Store } from 'n3';
import type { BaseQuad } from 'rdf-js'; import type { BaseQuad } from 'rdf-js';
import { someTerms } from 'rdf-terms'; import { someTerms } from 'rdf-terms';
import { Algebra } from 'sparqlalgebrajs'; import { Algebra } from 'sparqlalgebrajs';
import type { Patch } from '../../ldp/http/Patch';
import type { SparqlUpdatePatch } from '../../ldp/http/SparqlUpdatePatch'; import type { SparqlUpdatePatch } from '../../ldp/http/SparqlUpdatePatch';
import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation'; import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation';
import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
import { getLoggerFor } from '../../logging/LogUtil'; import { getLoggerFor } from '../../logging/LogUtil';
import { INTERNAL_QUADS } from '../../util/ContentTypes'; import { INTERNAL_QUADS } from '../../util/ContentTypes';
import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import type { RepresentationConverter } from '../conversion/RepresentationConverter';
import type { ResourceStore } from '../ResourceStore'; import type { ResourceStore } from '../ResourceStore';
import type { PatchHandlerArgs } from './PatchHandler';
import { PatchHandler } from './PatchHandler'; import { PatchHandler } from './PatchHandler';
/** /**
* PatchHandler that supports specific types of SPARQL updates. * PatchHandler that supports specific types of SPARQL updates.
* Currently all DELETE/INSERT types are supported that have empty where bodies and no variables. * Currently all DELETE/INSERT types are supported that have empty where bodies and no variables.
*
* Will try to keep the content-type and metadata of the original resource intact.
* In case this PATCH would create a new resource, it will have content-type `defaultType`.
*/ */
export class SparqlUpdatePatchHandler extends PatchHandler { export class SparqlUpdatePatchHandler extends PatchHandler {
protected readonly logger = getLoggerFor(this); protected readonly logger = getLoggerFor(this);
private readonly source: ResourceStore; private readonly converter: RepresentationConverter;
private readonly defaultType: string;
public constructor(source: ResourceStore) { public constructor(converter: RepresentationConverter, defaultType = 'text/turtle') {
super(); super();
this.source = source; this.converter = converter;
this.defaultType = defaultType;
} }
public async canHandle(input: {identifier: ResourceIdentifier; patch: SparqlUpdatePatch}): Promise<void> { public async canHandle({ patch }: PatchHandlerArgs): Promise<void> {
if (typeof input.patch.algebra !== 'object') { if (!this.isSparqlUpdate(patch)) {
throw new NotImplementedHttpError('Only SPARQL update patch requests are supported'); throw new NotImplementedHttpError('Only SPARQL update patches are supported');
} }
} }
public async handle(input: {identifier: ResourceIdentifier; patch: SparqlUpdatePatch}): public async handle(input: PatchHandlerArgs): Promise<ResourceIdentifier[]> {
Promise<ResourceIdentifier[]> {
// Verify the patch // Verify the patch
const { identifier, patch } = input; const { source, identifier, patch } = input;
const op = patch.algebra; const op = (patch as SparqlUpdatePatch).algebra;
this.validateUpdate(op); this.validateUpdate(op);
return this.applyPatch(identifier, op); return this.applyPatch(source, identifier, op);
}
private isSparqlUpdate(patch: Patch): patch is SparqlUpdatePatch {
return typeof (patch as SparqlUpdatePatch).algebra === 'object';
} }
private isDeleteInsert(op: Algebra.Operation): op is Algebra.DeleteInsert { private isDeleteInsert(op: Algebra.Operation): op is Algebra.DeleteInsert {
@ -108,33 +120,51 @@ export class SparqlUpdatePatchHandler extends PatchHandler {
/** /**
* Apply the given algebra operation to the given identifier. * Apply the given algebra operation to the given identifier.
*/ */
private async applyPatch(identifier: ResourceIdentifier, op: Algebra.Operation): Promise<ResourceIdentifier[]> { private async applyPatch(source: ResourceStore, identifier: ResourceIdentifier, op: Algebra.Operation):
const store = new Store<BaseQuad>(); Promise<ResourceIdentifier[]> {
// These are used to make sure we keep the original content-type and metadata
let contentType: string;
let metadata: RepresentationMetadata;
const result = new Store<BaseQuad>();
try { try {
// Read the quads of the current representation // Read the quads of the current representation
const quads = await this.source.getRepresentation(identifier, const representation = await source.getRepresentation(identifier, {});
{ type: { [INTERNAL_QUADS]: 1 }}); contentType = representation.metadata.contentType ?? this.defaultType;
const importEmitter = store.import(quads.data); const preferences = { type: { [INTERNAL_QUADS]: 1 }};
const quads = await this.converter.handleSafe({ representation, identifier, preferences });
// eslint-disable-next-line prefer-destructuring
metadata = quads.metadata;
const importEmitter = result.import(quads.data);
await new Promise((resolve, reject): void => { await new Promise((resolve, reject): void => {
importEmitter.on('end', resolve); importEmitter.on('end', resolve);
importEmitter.on('error', reject); importEmitter.on('error', reject);
}); });
this.logger.debug(`${store.size} quads in ${identifier.path}.`); this.logger.debug(`${result.size} quads in ${identifier.path}.`);
} catch (error: unknown) { } catch (error: unknown) {
// Solid, §5.1: "Clients who want to assign a URI to a resource, MUST use PUT and PATCH requests." // Solid, §5.1: "When a successful PUT or PATCH request creates a resource,
// the server MUST use the effective request URI to assign the URI to that resource."
// https://solid.github.io/specification/protocol#resource-type-heuristics // https://solid.github.io/specification/protocol#resource-type-heuristics
if (!NotFoundHttpError.isInstance(error)) { if (!NotFoundHttpError.isInstance(error)) {
throw error; throw error;
} }
contentType = this.defaultType;
metadata = new RepresentationMetadata(identifier, INTERNAL_QUADS);
this.logger.debug(`Patching new resource ${identifier.path}.`); this.logger.debug(`Patching new resource ${identifier.path}.`);
} }
this.applyOperation(store, op); this.applyOperation(result, op);
this.logger.debug(`${store.size} quads will be stored to ${identifier.path}.`); this.logger.debug(`${result.size} quads will be stored to ${identifier.path}.`);
// Write the result // Convert back to the original type and write the result
const patched = new BasicRepresentation(store.match() as Readable, INTERNAL_QUADS); const patched = new BasicRepresentation(result.match() as Readable, metadata);
return this.source.setRepresentation(identifier, patched); const converted = await this.converter.handleSafe({
representation: patched,
identifier,
preferences: { type: { [contentType]: 1 }},
});
return source.setRepresentation(identifier, converted);
} }
/** /**

View File

@ -35,6 +35,6 @@ describe('A PatchingStore', (): void => {
expect(source.modifyResource).toHaveBeenLastCalledWith({ path: 'modifyPath' }, {}, undefined); expect(source.modifyResource).toHaveBeenLastCalledWith({ path: 'modifyPath' }, {}, undefined);
await expect((source.modifyResource as jest.Mock).mock.results[0].value).rejects.toThrow('dummy'); await expect((source.modifyResource as jest.Mock).mock.results[0].value).rejects.toThrow('dummy');
expect(handleSafeFn).toHaveBeenCalledTimes(1); expect(handleSafeFn).toHaveBeenCalledTimes(1);
expect(handleSafeFn).toHaveBeenLastCalledWith({ identifier: { path: 'modifyPath' }, patch: {}}); expect(handleSafeFn).toHaveBeenLastCalledWith({ source, identifier: { path: 'modifyPath' }, patch: {}});
}); });
}); });

View File

@ -6,6 +6,8 @@ import { translate } from 'sparqlalgebrajs';
import type { SparqlUpdatePatch } from '../../../../src/ldp/http/SparqlUpdatePatch'; import type { SparqlUpdatePatch } from '../../../../src/ldp/http/SparqlUpdatePatch';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
import type { RepresentationConverterArgs,
RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter';
import { SparqlUpdatePatchHandler } from '../../../../src/storage/patch/SparqlUpdatePatchHandler'; import { SparqlUpdatePatchHandler } from '../../../../src/storage/patch/SparqlUpdatePatchHandler';
import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes'; import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes';
@ -13,9 +15,12 @@ import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
describe('A SparqlUpdatePatchHandler', (): void => { describe('A SparqlUpdatePatchHandler', (): void => {
let converter: RepresentationConverter;
let handler: SparqlUpdatePatchHandler; let handler: SparqlUpdatePatchHandler;
let source: ResourceStore; let source: ResourceStore;
let startQuads: Quad[]; let startQuads: Quad[];
const dummyType = 'internal/not-quads';
const identifier = { path: 'http://test.com/foo' };
const fullfilledDataInsert = 'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 . }'; const fullfilledDataInsert = 'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 . }';
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
@ -29,43 +34,60 @@ describe('A SparqlUpdatePatchHandler', (): void => {
namedNode('http://test.com/startO2'), namedNode('http://test.com/startO2'),
) ]; ) ];
converter = {
handleSafe: jest.fn(async({ representation, preferences }: RepresentationConverterArgs): Promise<any> =>
new BasicRepresentation(representation.data, Object.keys(preferences.type!)[0])),
} as unknown as RepresentationConverter;
source = { source = {
getRepresentation: jest.fn(async(): Promise<any> => new BasicRepresentation(startQuads, 'internal/quads', false)), getRepresentation: jest.fn(async(): Promise<any> => new BasicRepresentation(startQuads, dummyType)),
setRepresentation: jest.fn(), setRepresentation: jest.fn(),
modifyResource: jest.fn(async(): Promise<any> => { modifyResource: jest.fn(async(): Promise<any> => {
throw new Error('noModify'); throw new Error('noModify');
}), }),
} as unknown as ResourceStore; } as unknown as ResourceStore;
handler = new SparqlUpdatePatchHandler(source); handler = new SparqlUpdatePatchHandler(converter, dummyType);
}); });
async function basicChecks(quads: Quad[]): Promise<boolean> { async function basicChecks(quads: Quad[]): Promise<boolean> {
expect(source.getRepresentation).toHaveBeenCalledTimes(1); expect(source.getRepresentation).toHaveBeenCalledTimes(1);
expect(source.getRepresentation).toHaveBeenLastCalledWith( expect(source.getRepresentation).toHaveBeenLastCalledWith(identifier, { });
{ path: 'path' }, { type: { [INTERNAL_QUADS]: 1 }}, expect(converter.handleSafe).toHaveBeenCalledTimes(2);
); expect(converter.handleSafe).toHaveBeenCalledWith({
representation: await (source.getRepresentation as jest.Mock).mock.results[0].value,
identifier,
preferences: { type: { [INTERNAL_QUADS]: 1 }},
});
expect(converter.handleSafe).toHaveBeenLastCalledWith({
representation: expect.objectContaining({ binary: false, metadata: expect.any(RepresentationMetadata) }),
identifier,
preferences: { type: { [dummyType]: 1 }},
});
expect(source.setRepresentation).toHaveBeenCalledTimes(1); expect(source.setRepresentation).toHaveBeenCalledTimes(1);
const setParams = (source.setRepresentation as jest.Mock).mock.calls[0]; const setParams = (source.setRepresentation as jest.Mock).mock.calls[0];
expect(setParams[0]).toEqual({ path: 'path' }); expect(setParams[0]).toEqual(identifier);
expect(setParams[1]).toEqual(expect.objectContaining({ expect(setParams[1]).toEqual(expect.objectContaining({
binary: false, binary: true,
metadata: expect.any(RepresentationMetadata), metadata: expect.any(RepresentationMetadata),
})); }));
expect(setParams[1].metadata.contentType).toEqual(INTERNAL_QUADS); expect(setParams[1].metadata.contentType).toEqual(dummyType);
await expect(arrayifyStream(setParams[1].data)).resolves.toBeRdfIsomorphic(quads); await expect(arrayifyStream(setParams[1].data)).resolves.toBeRdfIsomorphic(quads);
return true; return true;
} }
async function handle(query: string): Promise<void> { async function handle(query: string): Promise<void> {
const sparqlPrefix = 'prefix : <http://test.com/>\n'; const sparqlPrefix = 'prefix : <http://test.com/>\n';
await handler.handle({ identifier: { path: 'path' }, await handler.handle({
patch: { algebra: translate(sparqlPrefix.concat(query), { quads: true }) } as SparqlUpdatePatch }); source,
identifier,
patch: { algebra: translate(sparqlPrefix.concat(query), { quads: true }) } as SparqlUpdatePatch,
});
} }
it('only accepts SPARQL updates.', async(): Promise<void> => { it('only accepts SPARQL updates.', async(): Promise<void> => {
const input = { identifier: { path: 'path' }, const input = { source, identifier, patch: { algebra: {}} as SparqlUpdatePatch };
patch: { algebra: {}} as SparqlUpdatePatch };
await expect(handler.canHandle(input)).resolves.toBeUndefined(); await expect(handler.canHandle(input)).resolves.toBeUndefined();
delete (input.patch as any).algebra; delete (input.patch as any).algebra;
await expect(handler.canHandle(input)).rejects.toThrow(NotImplementedHttpError); await expect(handler.canHandle(input)).rejects.toThrow(NotImplementedHttpError);
@ -181,13 +203,45 @@ describe('A SparqlUpdatePatchHandler', (): void => {
it('creates a new resource if it does not exist yet.', async(): Promise<void> => { it('creates a new resource if it does not exist yet.', async(): Promise<void> => {
startQuads = []; startQuads = [];
source.getRepresentation = jest.fn((): any => { (source.getRepresentation as jest.Mock).mockRejectedValueOnce(new NotFoundHttpError());
throw new NotFoundHttpError();
});
const query = 'INSERT DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>. }'; const query = 'INSERT DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>. }';
await handle(query); await handle(query);
expect(converter.handleSafe).toHaveBeenCalledTimes(1);
expect(converter.handleSafe).toHaveBeenLastCalledWith({
representation: expect.objectContaining({ binary: false, metadata: expect.any(RepresentationMetadata) }),
identifier,
preferences: { type: { [dummyType]: 1 }},
});
const quads =
[ quad(namedNode('http://test.com/s1'), namedNode('http://test.com/p1'), namedNode('http://test.com/o1')) ];
expect(source.setRepresentation).toHaveBeenCalledTimes(1);
const setParams = (source.setRepresentation as jest.Mock).mock.calls[0];
expect(setParams[1].metadata.contentType).toEqual(dummyType);
await expect(arrayifyStream(setParams[1].data)).resolves.toBeRdfIsomorphic(quads);
});
it('can handle representations without content-type.', async(): Promise<void> => {
(source.getRepresentation as jest.Mock).mockResolvedValueOnce(
new BasicRepresentation(startQuads, new RepresentationMetadata()),
);
await handle(fullfilledDataInsert);
expect(await basicChecks(startQuads.concat( expect(await basicChecks(startQuads.concat(
[ quad(namedNode('http://test.com/s1'), namedNode('http://test.com/p1'), namedNode('http://test.com/o1')) ], [ quad(namedNode('http://test.com/s1'), namedNode('http://test.com/p1'), namedNode('http://test.com/o1')),
quad(namedNode('http://test.com/s2'), namedNode('http://test.com/p2'), namedNode('http://test.com/o2')) ],
))).toBe(true); ))).toBe(true);
}); });
it('defaults to text/turtle if no default type was set.', async(): Promise<void> => {
handler = new SparqlUpdatePatchHandler(converter);
startQuads = [];
(source.getRepresentation as jest.Mock).mockRejectedValueOnce(new NotFoundHttpError());
const query = 'INSERT DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>. }';
await handle(query);
expect(source.setRepresentation).toHaveBeenCalledTimes(1);
const setParams = (source.setRepresentation as jest.Mock).mock.calls[0];
expect(setParams[1].metadata.contentType).toEqual('text/turtle');
});
}); });