feat: Create base ConvertingPatchHandler abstract class

This commit is contained in:
Joachim Van Herwegen 2021-06-21 10:20:01 +02:00
parent c18b8526cc
commit 25f33ee4cd
5 changed files with 247 additions and 86 deletions

View File

@ -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';

View File

@ -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<ResourceIdentifier[]> {
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<Representation>;
}

View File

@ -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<void> {
@ -45,7 +37,7 @@ export class SparqlUpdatePatchHandler extends PatchHandler {
public async handle(input: PatchHandlerArgs): Promise<ResourceIdentifier[]> {
// 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<ResourceIdentifier[]> {
// 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<Representation> {
const { identifier, patch } = input;
const result = new Store<BaseQuad>();
let metadata: RepresentationMetadata;
const result = new Store<BaseQuad>();
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);
}
/**

View File

@ -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<Representation> {
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<ResourceStore>;
let args: PatchHandlerArgs;
let converter: jest.Mocked<RepresentationConverter>;
let handler: jest.Mocked<SimpleConvertingPatchHandler>;
beforeEach(async(): Promise<void> => {
converter = {
handleSafe: jest.fn(async({ preferences }: RepresentationConverterArgs): Promise<any> =>
new BasicRepresentation('converted', Object.keys(preferences.type!)[0])),
} as any;
source = {
getRepresentation: jest.fn().mockResolvedValue(representation),
setRepresentation: jest.fn(async(id: ResourceIdentifier): Promise<ResourceIdentifier[]> => [ 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<void> => {
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<void> => {
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<void> => {
const error = new Error('bad data');
source.getRepresentation.mockRejectedValueOnce(error);
await expect(handler.handle(args)).rejects.toThrow(error);
});
});

View File

@ -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<RepresentationConverter>;
let handler: SparqlUpdatePatchHandler;
let source: ResourceStore;
let source: jest.Mocked<ResourceStore>;
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<void> => {
startQuads = [ quad(
@ -37,17 +37,14 @@ describe('A SparqlUpdatePatchHandler', (): void => {
converter = {
handleSafe: jest.fn(async({ representation, preferences }: RepresentationConverterArgs): Promise<any> =>
new BasicRepresentation(representation.data, Object.keys(preferences.type!)[0])),
} as unknown as RepresentationConverter;
} as any;
source = {
getRepresentation: jest.fn(async(): Promise<any> => new BasicRepresentation(startQuads, dummyType)),
getRepresentation: jest.fn(async(): Promise<any> => new BasicRepresentation(startQuads, defaultType)),
setRepresentation: jest.fn(),
modifyResource: jest.fn(async(): Promise<any> => {
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<boolean> {
@ -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<void> => {
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<void> => {
source.getRepresentation = jest.fn(async(): Promise<any> => {
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<void> => {
startQuads = [];
(source.getRepresentation as jest.Mock).mockRejectedValueOnce(new NotFoundHttpError());
source.getRepresentation.mockRejectedValueOnce(new NotFoundHttpError());
const query = 'INSERT DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>. }';
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<void> => {
(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<void> => {
handler = new SparqlUpdatePatchHandler(converter);
startQuads = [];
(source.getRepresentation as jest.Mock).mockRejectedValueOnce(new NotFoundHttpError());
source.getRepresentation.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];
const setParams = source.setRepresentation.mock.calls[0];
expect(setParams[1].metadata.contentType).toEqual('text/turtle');
});
});