diff --git a/src/index.ts b/src/index.ts index 5e0ad9aad..9601b9e77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -250,6 +250,7 @@ export * from './storage/mapping/FixedContentTypeMapper'; export * from './storage/mapping/SubdomainExtensionBasedMapper'; // Storage/Patch +export * from './storage/patch/ConvertingPatchHandler'; export * from './storage/patch/PatchHandler'; export * from './storage/patch/SparqlUpdatePatchHandler'; diff --git a/src/storage/patch/ConvertingPatchHandler.ts b/src/storage/patch/ConvertingPatchHandler.ts new file mode 100644 index 000000000..69afb0c36 --- /dev/null +++ b/src/storage/patch/ConvertingPatchHandler.ts @@ -0,0 +1,89 @@ +import type { Representation } from '../../ldp/representation/Representation'; +import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; +import { getLoggerFor } from '../../logging/LogUtil'; +import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; +import type { RepresentationConverter } from '../conversion/RepresentationConverter'; +import type { ResourceStore } from '../ResourceStore'; +import type { PatchHandlerArgs } from './PatchHandler'; +import { PatchHandler } from './PatchHandler'; + +/** + * An abstract patch handler. + * + * A `ConvertingPatchHandler` converts a document to its `intermediateType`, + * handles the patch operation, and then converts back to its original type. + * This abstract class covers all of the above except of handling the patch operation, + * for which the abstract `patch` function has to be implemented. + * + * In case there is no resource yet and a new one needs to be created, + * the `patch` function will be called without a Representation + * and the result will be converted to the `defaultType`. + */ +export abstract class ConvertingPatchHandler extends PatchHandler { + protected readonly logger = getLoggerFor(this); + + private readonly converter: RepresentationConverter; + protected readonly intermediateType: string; + protected readonly defaultType: string; + + /** + * @param converter - Converter that will be used to generate intermediate Representation. + * @param intermediateType - Content-type of the intermediate Representation. + * @param defaultType - Content-type in case a new resource gets created. + */ + protected constructor(converter: RepresentationConverter, intermediateType: string, defaultType: string) { + super(); + this.converter = converter; + this.intermediateType = intermediateType; + this.defaultType = defaultType; + } + + public async handle(input: PatchHandlerArgs): Promise { + const { source, identifier } = input; + const { representation, contentType } = await this.toIntermediate(source, identifier); + + const patched = await this.patch(input, representation); + + // Convert back to the original type and write the result + const converted = await this.converter.handleSafe({ + representation: patched, + identifier, + preferences: { type: { [contentType]: 1 }}, + }); + return source.setRepresentation(identifier, converted); + } + + /** + * Acquires the resource from the source and converts it to the intermediate type if it was found. + * Also returns the contentType that should be used when converting back before setting the representation. + */ + protected async toIntermediate(source: ResourceStore, identifier: ResourceIdentifier): + Promise<{ representation?: Representation; contentType: string }> { + let converted: Representation | undefined; + let contentType: string; + try { + const representation = await source.getRepresentation(identifier, {}); + contentType = representation.metadata.contentType!; + const preferences = { type: { [this.intermediateType]: 1 }}; + converted = await this.converter.handleSafe({ representation, identifier, preferences }); + } catch (error: unknown) { + // 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 + if (!NotFoundHttpError.isInstance(error)) { + throw error; + } + contentType = this.defaultType; + this.logger.debug(`Patching new resource ${identifier.path}`); + } + return { representation: converted, contentType }; + } + + /** + * Patch the given representation based on the patch arguments. + * In case representation is not defined a new Representation should be created. + * @param input - Arguments that were passed to the initial `handle` call. + * @param representation - Representation acquired from the source and converted to the intermediate type. + */ + protected abstract patch(input: PatchHandlerArgs, representation?: Representation): Promise; +} diff --git a/src/storage/patch/SparqlUpdatePatchHandler.ts b/src/storage/patch/SparqlUpdatePatchHandler.ts index 48d17ed80..45d1f45f2 100644 --- a/src/storage/patch/SparqlUpdatePatchHandler.ts +++ b/src/storage/patch/SparqlUpdatePatchHandler.ts @@ -7,34 +7,26 @@ import { Algebra } from 'sparqlalgebrajs'; import type { Patch } from '../../ldp/http/Patch'; import type { SparqlUpdatePatch } from '../../ldp/http/SparqlUpdatePatch'; import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation'; +import type { Representation } from '../../ldp/representation/Representation'; import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata'; import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; import { getLoggerFor } from '../../logging/LogUtil'; import { INTERNAL_QUADS } from '../../util/ContentTypes'; -import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import type { RepresentationConverter } from '../conversion/RepresentationConverter'; -import type { ResourceStore } from '../ResourceStore'; +import { ConvertingPatchHandler } from './ConvertingPatchHandler'; import type { PatchHandlerArgs } from './PatchHandler'; -import { PatchHandler } from './PatchHandler'; /** - * PatchHandler that supports specific types of SPARQL updates. - * Currently all DELETE/INSERT types are supported that have empty where bodies and no variables. + * Supports application/sparql-update PATCH requests on RDF resources. * - * 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`. + * Only DELETE/INSERT updates without variables are supported. */ -export class SparqlUpdatePatchHandler extends PatchHandler { +export class SparqlUpdatePatchHandler extends ConvertingPatchHandler { protected readonly logger = getLoggerFor(this); - private readonly converter: RepresentationConverter; - private readonly defaultType: string; - public constructor(converter: RepresentationConverter, defaultType = 'text/turtle') { - super(); - this.converter = converter; - this.defaultType = defaultType; + super(converter, INTERNAL_QUADS, defaultType); } public async canHandle({ patch }: PatchHandlerArgs): Promise { @@ -45,7 +37,7 @@ export class SparqlUpdatePatchHandler extends PatchHandler { public async handle(input: PatchHandlerArgs): Promise { // Verify the patch - const { source, identifier, patch } = input; + const { patch } = input; const op = (patch as SparqlUpdatePatch).algebra; // In case of a NOP we can skip everything @@ -55,7 +47,8 @@ export class SparqlUpdatePatchHandler extends PatchHandler { this.validateUpdate(op); - return this.applyPatch(source, identifier, op); + // Only start conversion if we know the operation is valid + return super.handle(input); } private isSparqlUpdate(patch: Patch): patch is SparqlUpdatePatch { @@ -126,51 +119,27 @@ export class SparqlUpdatePatchHandler extends PatchHandler { /** * Apply the given algebra operation to the given identifier. */ - private async applyPatch(source: ResourceStore, identifier: ResourceIdentifier, op: Algebra.Operation): - Promise { - // These are used to make sure we keep the original content-type and metadata - let contentType: string; + protected async patch(input: PatchHandlerArgs, representation?: Representation): Promise { + const { identifier, patch } = input; + const result = new Store(); let metadata: RepresentationMetadata; - const result = new Store(); - try { - // Read the quads of the current representation - const representation = await source.getRepresentation(identifier, {}); - contentType = representation.metadata.contentType ?? this.defaultType; - 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); + if (representation) { + ({ metadata } = representation); + const importEmitter = result.import(representation.data); await new Promise((resolve, reject): void => { importEmitter.on('end', resolve); importEmitter.on('error', reject); }); this.logger.debug(`${result.size} quads in ${identifier.path}.`); - } catch (error: unknown) { - // 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 - if (!NotFoundHttpError.isInstance(error)) { - throw error; - } - contentType = this.defaultType; + } else { metadata = new RepresentationMetadata(identifier, INTERNAL_QUADS); - this.logger.debug(`Patching new resource ${identifier.path}.`); } - this.applyOperation(result, op); + this.applyOperation(result, (patch as SparqlUpdatePatch).algebra); this.logger.debug(`${result.size} quads will be stored to ${identifier.path}.`); - // Convert back to the original type and write the result - const patched = new BasicRepresentation(result.match() as Readable, metadata); - const converted = await this.converter.handleSafe({ - representation: patched, - identifier, - preferences: { type: { [contentType]: 1 }}, - }); - return source.setRepresentation(identifier, converted); + return new BasicRepresentation(result.match() as Readable, metadata); } /** diff --git a/test/unit/storage/patch/ConvertingPatchHandlers.test.ts b/test/unit/storage/patch/ConvertingPatchHandlers.test.ts new file mode 100644 index 000000000..7e7271827 --- /dev/null +++ b/test/unit/storage/patch/ConvertingPatchHandlers.test.ts @@ -0,0 +1,118 @@ +import type { Patch } from '../../../../src/ldp/http/Patch'; +import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; +import type { Representation } from '../../../../src/ldp/representation/Representation'; +import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; +import type { + RepresentationConverter, + RepresentationConverterArgs, +} from '../../../../src/storage/conversion/RepresentationConverter'; +import { ConvertingPatchHandler } from '../../../../src/storage/patch/ConvertingPatchHandler'; +import type { PatchHandlerArgs } from '../../../../src/storage/patch/PatchHandler'; +import type { ResourceStore } from '../../../../src/storage/ResourceStore'; +import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; + +class SimpleConvertingPatchHandler extends ConvertingPatchHandler { + private readonly type: string; + + public constructor(converter: RepresentationConverter, intermediateType: string, defaultType: string) { + super(converter, intermediateType, defaultType); + this.type = intermediateType; + } + + public async patch(input: PatchHandlerArgs, representation?: Representation): Promise { + return representation ? + new BasicRepresentation('patched', representation.metadata) : + new BasicRepresentation('patched', input.identifier, this.type); + } +} + +describe('A ConvertingPatchHandler', (): void => { + const intermediateType = 'internal/quads'; + const defaultType = 'text/turtle'; + const identifier: ResourceIdentifier = { path: 'http://test.com/foo' }; + const patch: Patch = new BasicRepresentation([], 'type/patch'); + const representation: Representation = new BasicRepresentation([], 'application/trig'); + let source: jest.Mocked; + let args: PatchHandlerArgs; + let converter: jest.Mocked; + let handler: jest.Mocked; + + beforeEach(async(): Promise => { + converter = { + handleSafe: jest.fn(async({ preferences }: RepresentationConverterArgs): Promise => + new BasicRepresentation('converted', Object.keys(preferences.type!)[0])), + } as any; + + source = { + getRepresentation: jest.fn().mockResolvedValue(representation), + setRepresentation: jest.fn(async(id: ResourceIdentifier): Promise => [ id ]), + } as any; + + args = { patch, identifier, source }; + + handler = new SimpleConvertingPatchHandler(converter, intermediateType, defaultType) as any; + jest.spyOn(handler, 'patch'); + }); + + it('converts the representation before calling the patch function.', async(): Promise => { + await expect(handler.handle(args)).resolves.toEqual([ identifier ]); + + // Convert input + expect(source.getRepresentation).toHaveBeenCalledTimes(1); + expect(source.getRepresentation).toHaveBeenLastCalledWith(identifier, { }); + expect(converter.handleSafe).toHaveBeenCalledTimes(2); + expect(converter.handleSafe).toHaveBeenCalledWith({ + representation: await source.getRepresentation.mock.results[0].value, + identifier, + preferences: { type: { [intermediateType]: 1 }}, + }); + + // Patch + expect(handler.patch).toHaveBeenCalledTimes(1); + expect(handler.patch).toHaveBeenLastCalledWith(args, await converter.handleSafe.mock.results[0].value); + + // Convert back + expect(converter.handleSafe).toHaveBeenLastCalledWith({ + representation: await handler.patch.mock.results[0].value, + identifier, + preferences: { type: { 'application/trig': 1 }}, + }); + expect(source.setRepresentation).toHaveBeenCalledTimes(1); + expect(source.setRepresentation) + .toHaveBeenLastCalledWith(identifier, await converter.handleSafe.mock.results[1].value); + expect(source.setRepresentation.mock.calls[0][1].metadata.contentType).toBe(representation.metadata.contentType); + }); + + it('expects the patch function to create a new representation if there is none.', async(): Promise => { + source.getRepresentation.mockRejectedValueOnce(new NotFoundHttpError()); + + await expect(handler.handle(args)).resolves.toEqual([ identifier ]); + + // Try to get input + expect(source.getRepresentation).toHaveBeenCalledTimes(1); + expect(source.getRepresentation).toHaveBeenLastCalledWith(identifier, { }); + + // Patch + expect(handler.patch).toHaveBeenCalledTimes(1); + expect(handler.patch).toHaveBeenLastCalledWith(args, undefined); + + // Convert new Representation to default type + expect(converter.handleSafe).toHaveBeenCalledTimes(1); + expect(converter.handleSafe).toHaveBeenLastCalledWith({ + representation: await handler.patch.mock.results[0].value, + identifier, + preferences: { type: { [defaultType]: 1 }}, + }); + expect(source.setRepresentation).toHaveBeenCalledTimes(1); + expect(source.setRepresentation) + .toHaveBeenLastCalledWith(identifier, await converter.handleSafe.mock.results[0].value); + expect(source.setRepresentation.mock.calls[0][1].metadata.contentType).toBe(defaultType); + }); + + it('rethrows the error if something goes wrong getting the representation.', async(): Promise => { + const error = new Error('bad data'); + source.getRepresentation.mockRejectedValueOnce(error); + + await expect(handler.handle(args)).rejects.toThrow(error); + }); +}); diff --git a/test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts b/test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts index 03a9eb5cc..b44982db0 100644 --- a/test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts +++ b/test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts @@ -15,13 +15,13 @@ import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; describe('A SparqlUpdatePatchHandler', (): void => { - let converter: RepresentationConverter; + let converter: jest.Mocked; let handler: SparqlUpdatePatchHandler; - let source: ResourceStore; + let source: jest.Mocked; let startQuads: Quad[]; - const dummyType = 'internal/not-quads'; + const defaultType = 'internal/not-quads'; const identifier = { path: 'http://test.com/foo' }; - const fullfilledDataInsert = 'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 . }'; + const fulfilledDataInsert = 'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 . }'; beforeEach(async(): Promise => { startQuads = [ quad( @@ -37,17 +37,14 @@ describe('A SparqlUpdatePatchHandler', (): void => { converter = { handleSafe: jest.fn(async({ representation, preferences }: RepresentationConverterArgs): Promise => new BasicRepresentation(representation.data, Object.keys(preferences.type!)[0])), - } as unknown as RepresentationConverter; + } as any; source = { - getRepresentation: jest.fn(async(): Promise => new BasicRepresentation(startQuads, dummyType)), + getRepresentation: jest.fn(async(): Promise => new BasicRepresentation(startQuads, defaultType)), setRepresentation: jest.fn(), - modifyResource: jest.fn(async(): Promise => { - throw new Error('noModify'); - }), - } as unknown as ResourceStore; + } as any; - handler = new SparqlUpdatePatchHandler(converter, dummyType); + handler = new SparqlUpdatePatchHandler(converter, defaultType); }); async function basicChecks(quads: Quad[]): Promise { @@ -55,24 +52,24 @@ describe('A SparqlUpdatePatchHandler', (): void => { expect(source.getRepresentation).toHaveBeenLastCalledWith(identifier, { }); expect(converter.handleSafe).toHaveBeenCalledTimes(2); expect(converter.handleSafe).toHaveBeenCalledWith({ - representation: await (source.getRepresentation as jest.Mock).mock.results[0].value, + representation: await source.getRepresentation.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 }}, + preferences: { type: { [defaultType]: 1 }}, }); expect(source.setRepresentation).toHaveBeenCalledTimes(1); - const setParams = (source.setRepresentation as jest.Mock).mock.calls[0]; + const setParams = source.setRepresentation.mock.calls[0]; expect(setParams[0]).toEqual(identifier); expect(setParams[1]).toEqual(expect.objectContaining({ binary: true, metadata: expect.any(RepresentationMetadata), })); - expect(setParams[1].metadata.contentType).toEqual(dummyType); + expect(setParams[1].metadata.contentType).toEqual(defaultType); await expect(arrayifyStream(setParams[1].data)).resolves.toBeRdfIsomorphic(quads); return true; } @@ -101,7 +98,7 @@ describe('A SparqlUpdatePatchHandler', (): void => { }); it('handles INSERT DATA updates.', async(): Promise => { - await handle(fullfilledDataInsert); + await handle(fulfilledDataInsert); 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/s2'), namedNode('http://test.com/p2'), namedNode('http://test.com/o2')) ], @@ -202,15 +199,13 @@ describe('A SparqlUpdatePatchHandler', (): void => { }); it('throws the error returned by the store if there is one.', async(): Promise => { - source.getRepresentation = jest.fn(async(): Promise => { - throw new Error('error'); - }); - await expect(handle(fullfilledDataInsert)).rejects.toThrow('error'); + source.getRepresentation.mockRejectedValueOnce(new Error('error')); + await expect(handle(fulfilledDataInsert)).rejects.toThrow('error'); }); it('creates a new resource if it does not exist yet.', async(): Promise => { startQuads = []; - (source.getRepresentation as jest.Mock).mockRejectedValueOnce(new NotFoundHttpError()); + source.getRepresentation.mockRejectedValueOnce(new NotFoundHttpError()); const query = 'INSERT DATA { . }'; await handle(query); @@ -218,37 +213,26 @@ describe('A SparqlUpdatePatchHandler', (): void => { expect(converter.handleSafe).toHaveBeenLastCalledWith({ representation: expect.objectContaining({ binary: false, metadata: expect.any(RepresentationMetadata) }), identifier, - preferences: { type: { [dummyType]: 1 }}, + preferences: { type: { [defaultType]: 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); + const setParams = source.setRepresentation.mock.calls[0]; + expect(setParams[1].metadata.contentType).toEqual(defaultType); await expect(arrayifyStream(setParams[1].data)).resolves.toBeRdfIsomorphic(quads); }); - it('can handle representations without content-type.', async(): Promise => { - (source.getRepresentation as jest.Mock).mockResolvedValueOnce( - new BasicRepresentation(startQuads, new RepresentationMetadata()), - ); - await handle(fullfilledDataInsert); - 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/s2'), namedNode('http://test.com/p2'), namedNode('http://test.com/o2')) ], - ))).toBe(true); - }); - it('defaults to text/turtle if no default type was set.', async(): Promise => { handler = new SparqlUpdatePatchHandler(converter); startQuads = []; - (source.getRepresentation as jest.Mock).mockRejectedValueOnce(new NotFoundHttpError()); + source.getRepresentation.mockRejectedValueOnce(new NotFoundHttpError()); const query = 'INSERT DATA { . }'; await handle(query); expect(source.setRepresentation).toHaveBeenCalledTimes(1); - const setParams = (source.setRepresentation as jest.Mock).mock.calls[0]; + const setParams = source.setRepresentation.mock.calls[0]; expect(setParams[1].metadata.contentType).toEqual('text/turtle'); }); });