feat: Patch containers by recreating Representation from metadata

Also included is a change to the Patching architecture.
Patching is now done by RepresentationPatchers that take a Representation as input.
This commit is contained in:
Joachim Van Herwegen 2021-09-13 15:32:15 +02:00
parent a1c3633a25
commit ef9703e284
18 changed files with 804 additions and 483 deletions

View File

@ -5,10 +5,26 @@
"comment": "Allows for PATCH operations on stores that don't have native support.",
"@id": "urn:solid-server:default:ResourceStore_Patching",
"@type": "PatchingStore",
"patcher": {
"patchHandler": {
"@id": "urn:solid-server:default:PatchHandler",
"@type": "SparqlUpdatePatchHandler",
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" }
"@type": "RepresentationPatchHandler",
"patcher": {
"@type": "WaterfallHandler",
"handlers": [
{
"comment": "Makes sure PATCH operations on containers target the metadata.",
"@type": "ContainerPatcher",
"patcher": { "@type": "SparqlUpdatePatcher" }
},
{
"@type": "ConvertingPatcher",
"patcher": { "@type": "SparqlUpdatePatcher" },
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
"intermediateType": "internal/quads",
"defaultType": "text/turtle"
}
]
}
}
}
]

View File

@ -259,9 +259,12 @@ export * from './storage/mapping/FixedContentTypeMapper';
export * from './storage/mapping/SubdomainExtensionBasedMapper';
// Storage/Patch
export * from './storage/patch/ConvertingPatchHandler';
export * from './storage/patch/ContainerPatcher';
export * from './storage/patch/ConvertingPatcher';
export * from './storage/patch/PatchHandler';
export * from './storage/patch/SparqlUpdatePatchHandler';
export * from './storage/patch/RepresentationPatcher';
export * from './storage/patch/RepresentationPatchHandler';
export * from './storage/patch/SparqlUpdatePatcher';
// Storage/Routing
export * from './storage/routing/BaseUrlRouterRule';

View File

@ -12,11 +12,11 @@ import type { ResourceStore } from './ResourceStore';
* otherwise the {@link PatchHandler} will be called instead.
*/
export class PatchingStore<T extends ResourceStore = ResourceStore> extends PassthroughStore<T> {
private readonly patcher: PatchHandler;
private readonly patchHandler: PatchHandler;
public constructor(source: T, patcher: PatchHandler) {
public constructor(source: T, patchHandler: PatchHandler) {
super(source);
this.patcher = patcher;
this.patchHandler = patchHandler;
}
public async modifyResource(identifier: ResourceIdentifier, patch: Patch,
@ -25,7 +25,7 @@ export class PatchingStore<T extends ResourceStore = ResourceStore> extends Pass
return await this.source.modifyResource(identifier, patch, conditions);
} catch (error: unknown) {
if (NotImplementedHttpError.isInstance(error)) {
return this.patcher.handleSafe({ source: this.source, identifier, patch });
return this.patchHandler.handleSafe({ source: this.source, identifier, patch });
}
throw error;
}

View File

@ -0,0 +1,51 @@
import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation';
import type { Representation } from '../../ldp/representation/Representation';
import { INTERNAL_QUADS } from '../../util/ContentTypes';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { isContainerIdentifier } from '../../util/PathUtil';
import { SOLID_META } from '../../util/Vocabularies';
import type { RepresentationPatcherInput } from './RepresentationPatcher';
import { RepresentationPatcher } from './RepresentationPatcher';
/**
* A `RepresentationPatcher` specifically for patching containers.
* A new body will be constructed from the metadata by removing all generated metadata.
* This body will be passed to the wrapped patcher.
*/
export class ContainerPatcher extends RepresentationPatcher {
private readonly patcher: RepresentationPatcher;
public constructor(patcher: RepresentationPatcher) {
super();
this.patcher = patcher;
}
public async canHandle(input: RepresentationPatcherInput): Promise<void> {
const { identifier, representation } = input;
if (!isContainerIdentifier(identifier)) {
throw new NotImplementedHttpError('Only containers are supported.');
}
// Verify the patcher can handle a representation containing the metadata
let containerPlaceholder = representation;
if (representation) {
containerPlaceholder = new BasicRepresentation([], representation.metadata, INTERNAL_QUADS);
}
await this.patcher.canHandle({ ...input, representation: containerPlaceholder });
}
public async handle(input: RepresentationPatcherInput): Promise<Representation> {
const { identifier, representation } = input;
if (!representation) {
return await this.patcher.handle(input);
}
// Remove all generated metadata to prevent it from being stored permanently
representation.metadata.removeQuads(
representation.metadata.quads(null, null, null, SOLID_META.terms.ResponseMetadata),
);
const quads = representation.metadata.quads();
// We do not copy the original metadata here, otherwise it would put back triples that might be deleted
const containerRepresentation = new BasicRepresentation(quads, identifier, INTERNAL_QUADS, false);
return await this.patcher.handle({ ...input, representation: containerRepresentation });
}
}

View File

@ -1,89 +0,0 @@
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

@ -0,0 +1,79 @@
import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation';
import type { Representation } from '../../ldp/representation/Representation';
import { getLoggerFor } from '../../logging/LogUtil';
import type { RepresentationConverter } from '../conversion/RepresentationConverter';
import type { RepresentationPatcherInput } from './RepresentationPatcher';
import { RepresentationPatcher } from './RepresentationPatcher';
/**
* A `ConvertingPatcher` converts a document to its `intermediateType`,
* sends the result to the wrapped patcher, and then converts back to its original type.
* No changes will take place if no `intermediateType` is provided.
*
* In case there is no resource yet and a new one needs to be created,
* the result of the wrapped patcher will be converted to the provided `defaultType`.
* In case no `defaultType` is provided, the patcher output will be returned directly.
*/
export class ConvertingPatcher extends RepresentationPatcher {
protected readonly logger = getLoggerFor(this);
private readonly patcher: RepresentationPatcher;
private readonly converter: RepresentationConverter;
private readonly intermediateType?: string;
private readonly defaultType?: string;
/**
* @param patcher - Patcher that will be called with the Representation.
* @param converter - Converter that will be used to generate intermediate Representation.
* @param intermediateType - Content-type of the intermediate Representation if conversion is needed.
* @param defaultType - Content-type in case a new resource gets created and needs to be converted.
*/
public constructor(patcher: RepresentationPatcher, converter: RepresentationConverter, intermediateType?: string,
defaultType?: string) {
super();
this.patcher = patcher;
this.converter = converter;
this.intermediateType = intermediateType;
this.defaultType = defaultType;
}
public async canHandle(input: RepresentationPatcherInput): Promise<void> {
// Verify the converter can handle the input representation if needed
const { identifier, representation } = input;
let convertedPlaceholder = representation;
if (representation && this.intermediateType) {
const preferences = { type: { [this.intermediateType]: 1 }};
await this.converter.canHandle({ representation, identifier, preferences });
convertedPlaceholder = new BasicRepresentation([], representation.metadata, this.intermediateType);
}
// Verify the patcher can handle the (converted) representation
await this.patcher.canHandle({ ...input, representation: convertedPlaceholder });
}
public async handle(input: RepresentationPatcherInput): Promise<Representation> {
const { identifier, representation } = input;
let outputType: string | undefined;
let converted = representation;
if (!representation) {
// If there is no representation the output will need to be converted to the default type
outputType = this.defaultType;
} else if (this.intermediateType) {
// Convert incoming representation to the requested type
outputType = representation.metadata.contentType;
const preferences = { type: { [this.intermediateType]: 1 }};
converted = await this.converter.handle({ representation, identifier, preferences });
}
// Call the wrapped patcher with the (potentially) converted representation
let result = await this.patcher.handle({ ...input, representation: converted });
// Convert the output back to its original type or the default type depending on what was set
if (outputType) {
const preferences = { type: { [outputType]: 1 }};
result = await this.converter.handle({ representation: result, identifier, preferences });
}
return result;
}
}

View File

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

View File

@ -0,0 +1,47 @@
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 { PatchHandlerInput } from './PatchHandler';
import { PatchHandler } from './PatchHandler';
import type { RepresentationPatcher } from './RepresentationPatcher';
/**
* Handles a patch operation by getting the representation from the store, applying a `RepresentationPatcher`,
* and then writing the result back to the store.
*
* In case there is no original representation (the store throws a `NotFoundHttpError`),
* the patcher is expected to create a new one.
*/
export class RepresentationPatchHandler extends PatchHandler {
protected readonly logger = getLoggerFor(this);
private readonly patcher: RepresentationPatcher;
public constructor(patcher: RepresentationPatcher) {
super();
this.patcher = patcher;
}
public async handle({ source, patch, identifier }: PatchHandlerInput): Promise<ResourceIdentifier[]> {
// Get the representation from the store
let representation: Representation | undefined;
try {
representation = await source.getRepresentation(identifier, {});
} 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;
}
this.logger.debug(`Patching new resource ${identifier.path}`);
}
// Patch it
const patched = await this.patcher.handleSafe({ patch, identifier, representation });
// Write it back to the store
return source.setRepresentation(identifier, patched);
}
}

View File

@ -0,0 +1,15 @@
import type { Patch } from '../../ldp/http/Patch';
import type { Representation } from '../../ldp/representation/Representation';
import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
export interface RepresentationPatcherInput {
identifier: ResourceIdentifier;
patch: Patch;
representation?: Representation;
}
/**
* Handles the patching of a specific Representation.
*/
export abstract class RepresentationPatcher extends AsyncHandler<RepresentationPatcherInput, Representation> {}

View File

@ -11,50 +11,48 @@ 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 { InternalServerError } from '../../util/errors/InternalServerError';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { readableToQuads, readableToString } from '../../util/StreamUtil';
import type { RepresentationConverter } from '../conversion/RepresentationConverter';
import { ConvertingPatchHandler } from './ConvertingPatchHandler';
import type { PatchHandlerArgs } from './PatchHandler';
import { RepresentationPatcher } from './RepresentationPatcher';
import type { RepresentationPatcherInput } from './RepresentationPatcher';
/**
* Supports application/sparql-update PATCH requests on RDF resources.
*
* Only DELETE/INSERT updates without variables are supported.
*/
export class SparqlUpdatePatchHandler extends ConvertingPatchHandler {
export class SparqlUpdatePatcher extends RepresentationPatcher {
protected readonly logger = getLoggerFor(this);
private readonly engine: ActorInitSparql;
public constructor(converter: RepresentationConverter, defaultType = 'text/turtle') {
super(converter, INTERNAL_QUADS, defaultType);
public constructor() {
super();
this.engine = newEngine();
}
public async canHandle({ patch }: PatchHandlerArgs): Promise<void> {
public async canHandle({ patch }: RepresentationPatcherInput): Promise<void> {
if (!this.isSparqlUpdate(patch)) {
throw new NotImplementedHttpError('Only SPARQL update patches are supported');
}
}
public async handle(input: PatchHandlerArgs): Promise<ResourceIdentifier[]> {
public async handle(input: RepresentationPatcherInput): Promise<Representation> {
// Verify the patch
const { patch } = input;
const { patch, representation, identifier } = input;
const op = (patch as SparqlUpdatePatch).algebra;
// In case of a NOP we can skip everything
if (op.type === Algebra.types.NOP) {
return [];
return representation ?? new BasicRepresentation([], identifier, INTERNAL_QUADS, false);
}
this.validateUpdate(op);
// Only start conversion if we know the operation is valid
return super.handle(input);
return this.patch(input);
}
private isSparqlUpdate(patch: Patch): patch is SparqlUpdatePatch {
@ -117,13 +115,15 @@ export class SparqlUpdatePatchHandler extends ConvertingPatchHandler {
/**
* Apply the given algebra operation to the given identifier.
*/
protected async patch(input: PatchHandlerArgs, representation?: Representation): Promise<Representation> {
const { identifier, patch } = input;
private async patch({ identifier, patch, representation }: RepresentationPatcherInput): Promise<Representation> {
let result: Store<BaseQuad>;
let metadata: RepresentationMetadata;
if (representation) {
({ metadata } = representation);
if (metadata.contentType !== INTERNAL_QUADS) {
throw new InternalServerError('Quad stream was expected for patching.');
}
result = await readableToQuads(representation.data);
this.logger.debug(`${result.size} quads in ${identifier.path}.`);
} else {
@ -139,6 +139,6 @@ export class SparqlUpdatePatchHandler extends ConvertingPatchHandler {
this.logger.debug(`${result.size} quads will be stored to ${identifier.path}.`);
return new BasicRepresentation(result.match() as unknown as Readable, metadata);
return new BasicRepresentation(result.match() as unknown as Readable, metadata, false);
}
}

View File

@ -358,4 +358,40 @@ describe.each(stores)('An LDP handler allowing all requests %s', (name, { storeC
// DELETE
expect(await deleteResource(documentUrl)).toBeUndefined();
});
it('can handle simple SPARQL updates on containers.', async(): Promise<void> => {
// POST
const body = [ '<http://test.com/s1> <http://test.com/p1> <http://test.com/o1>.',
'<http://test.com/s2> <http://test.com/p2> <http://test.com/o2>.' ].join('\n');
let response = await postResource(baseUrl, { contentType: 'text/turtle', body, isContainer: true });
const documentUrl = response.headers.get('location')!;
// PATCH
const query = [ 'DELETE { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1> }',
'INSERT {<http://test.com/s3> <http://test.com/p3> <http://test.com/o3>}',
'WHERE {}',
].join('\n');
await patchResource(documentUrl, query);
// GET
response = await getResource(documentUrl);
const parser = new Parser({ baseIRI: baseUrl });
const quads = parser.parse(await response.text());
const store = new Store(quads);
expect(store.countQuads(
namedNode('http://test.com/s3'),
namedNode('http://test.com/p3'),
namedNode('http://test.com/o3'),
null,
)).toBe(1);
expect(store.countQuads(
namedNode('http://test.com/s1'),
namedNode('http://test.com/p1'),
namedNode('http://test.com/o1'),
null,
)).toBe(0);
// DELETE
expect(await deleteResource(documentUrl)).toBeUndefined();
});
});

View File

@ -174,4 +174,23 @@ describe('A Solid server', (): void => {
});
expect(res.status).toBe(205);
});
it('can PATCH containers.', async(): Promise<void> => {
const url = `${baseUrl}containerPATCH/`;
await fetch(url, {
method: 'PUT',
headers: {
'content-type': 'text/turtle',
},
body: '<a:b> <a:b> <a:b>.',
});
const res = await fetch(url, {
method: 'PATCH',
headers: {
'content-type': 'application/sparql-update',
},
body: 'INSERT DATA { <b:b> <b:b> <b:b>. }',
});
expect(res.status).toBe(205);
});
});

View File

@ -0,0 +1,91 @@
import arrayifyStream from 'arrayify-stream';
import { DataFactory } from 'n3';
import type { Patch } from '../../../../src/ldp/http/Patch';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import type { Representation } from '../../../../src/ldp/representation/Representation';
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
import { ContainerPatcher } from '../../../../src/storage/patch/ContainerPatcher';
import type {
RepresentationPatcherInput,
RepresentationPatcher,
} from '../../../../src/storage/patch/RepresentationPatcher';
import { SOLID_META } from '../../../../src/util/Vocabularies';
const { namedNode, quad } = DataFactory;
describe('A ContainerPatcher', (): void => {
const identifier: ResourceIdentifier = { path: 'http://test.com/foo/' };
const patch: Patch = new BasicRepresentation([], 'type/patch');
let representation: Representation;
let args: RepresentationPatcherInput;
const patchResult = new BasicRepresentation([], 'internal/quads');
let patcher: jest.Mocked<RepresentationPatcher>;
let containerPatcher: ContainerPatcher;
beforeEach(async(): Promise<void> => {
representation = new BasicRepresentation([], 'internal/quads');
args = { patch, identifier, representation };
patcher = {
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue(patchResult),
} as any;
containerPatcher = new ContainerPatcher(patcher);
});
it('can only handle container identifiers.', async(): Promise<void> => {
args.identifier = { path: 'http://test.com/foo' };
await expect(containerPatcher.canHandle(args)).rejects.toThrow('Only containers are supported.');
});
it('checks if the patcher can handle the input if there is no representation.', async(): Promise<void> => {
delete args.representation;
await expect(containerPatcher.canHandle(args)).resolves.toBeUndefined();
patcher.canHandle.mockRejectedValueOnce(new Error('unsupported patch'));
await expect(containerPatcher.canHandle(args)).rejects.toThrow('unsupported patch');
});
it('sends a mock representation with the correct type to the patcher to check support.', async(): Promise<void> => {
await expect(containerPatcher.canHandle(args)).resolves.toBeUndefined();
expect(patcher.canHandle).toHaveBeenCalledTimes(1);
expect(patcher.canHandle.mock.calls[0][0].representation?.metadata.contentType).toBe('internal/quads');
});
it('passes the arguments to the patcher if there is no representation.', async(): Promise<void> => {
delete args.representation;
await expect(containerPatcher.handle(args)).resolves.toBe(patchResult);
expect(patcher.handle).toHaveBeenCalledTimes(1);
expect(patcher.handle).toHaveBeenLastCalledWith(args);
});
it('creates a new representation with all generated metadata removed.', async(): Promise<void> => {
const triples = [
quad(namedNode('a'), namedNode('real'), namedNode('triple')),
quad(namedNode('a'), namedNode('generated'), namedNode('triple')),
];
const metadata = new RepresentationMetadata(identifier);
metadata.addQuad(triples[0].subject as any, triples[0].predicate as any, triples[0].object as any);
// Make one of the triples generated
metadata.addQuad(triples[0].subject as any,
triples[0].predicate as any,
triples[0].object as any,
SOLID_META.terms.ResponseMetadata);
args.representation = new BasicRepresentation(triples, metadata);
await expect(containerPatcher.handle(args)).resolves.toBe(patchResult);
expect(patcher.handle).toHaveBeenCalledTimes(1);
const callArgs = patcher.handle.mock.calls[0][0];
expect(callArgs.identifier).toBe(identifier);
expect(callArgs.patch).toBe(patch);
// Only content-type metadata
expect(callArgs.representation?.metadata.quads()).toHaveLength(1);
expect(callArgs.representation?.metadata.contentType).toBe('internal/quads');
// Generated data got removed
const data = await arrayifyStream(callArgs.representation!.data);
expect(data).toHaveLength(1);
expect(data[0].predicate.value).toBe('real');
});
});

View File

@ -1,118 +0,0 @@
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

@ -0,0 +1,129 @@
import type { Patch } from '../../../../src/ldp/http/Patch';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
import type {
RepresentationConverter,
RepresentationConverterArgs,
} from '../../../../src/storage/conversion/RepresentationConverter';
import { ConvertingPatcher } from '../../../../src/storage/patch/ConvertingPatcher';
import type {
RepresentationPatcher,
RepresentationPatcherInput,
} from '../../../../src/storage/patch/RepresentationPatcher';
describe('A ConvertingPatcher', (): 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 = new BasicRepresentation([], 'application/trig');
const patchResult = new BasicRepresentation([], 'internal/quads');
let args: RepresentationPatcherInput;
let converter: jest.Mocked<RepresentationConverter>;
let patcher: jest.Mocked<RepresentationPatcher>;
let convertingPatcher: ConvertingPatcher;
beforeEach(async(): Promise<void> => {
args = { patch, identifier, representation };
converter = {
canHandle: jest.fn(),
handle: jest.fn(async({ preferences }: RepresentationConverterArgs): Promise<any> =>
new BasicRepresentation('converted', Object.keys(preferences.type!)[0])),
} as any;
patcher = {
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue(patchResult),
} as any;
convertingPatcher = new ConvertingPatcher(patcher, converter, intermediateType, defaultType);
});
it('rejects requests the converter cannot handle.', async(): Promise<void> => {
converter.canHandle.mockRejectedValueOnce(new Error('unsupported type'));
await expect(convertingPatcher.canHandle(args)).rejects.toThrow('unsupported type');
});
it('checks if the patcher can handle the input if there is no representation.', async(): Promise<void> => {
delete args.representation;
await expect(convertingPatcher.canHandle(args)).resolves.toBeUndefined();
patcher.canHandle.mockRejectedValueOnce(new Error('unsupported patch'));
await expect(convertingPatcher.canHandle(args)).rejects.toThrow('unsupported patch');
});
it('sends a mock representation with the correct type to the patcher to check support.', async(): Promise<void> => {
await expect(convertingPatcher.canHandle(args)).resolves.toBeUndefined();
expect(patcher.canHandle).toHaveBeenCalledTimes(1);
expect(patcher.canHandle.mock.calls[0][0].representation?.metadata.contentType).toBe(intermediateType);
});
it('converts the representation before calling the patcher.', async(): Promise<void> => {
const result = await convertingPatcher.handle(args);
expect(result.metadata.contentType).toBe('application/trig');
// Convert input
expect(converter.handle).toHaveBeenCalledTimes(2);
expect(converter.handle).toHaveBeenCalledWith({
representation,
identifier,
preferences: { type: { [intermediateType]: 1 }},
});
// Patch
expect(patcher.handle).toHaveBeenCalledTimes(1);
expect(patcher.handle)
.toHaveBeenLastCalledWith({ ...args, representation: await converter.handle.mock.results[0].value });
// Convert back
expect(converter.handle).toHaveBeenLastCalledWith({
representation: patchResult,
identifier,
preferences: { type: { 'application/trig': 1 }},
});
});
it('expects the patcher to create a new representation if there is none.', async(): Promise<void> => {
delete args.representation;
const result = await convertingPatcher.handle(args);
expect(result.metadata.contentType).toBe(defaultType);
// Patch
expect(patcher.handle).toHaveBeenCalledTimes(1);
expect(patcher.handle).toHaveBeenLastCalledWith(args);
// Convert new Representation to default type
expect(converter.handle).toHaveBeenCalledTimes(1);
expect(converter.handle).toHaveBeenLastCalledWith({
representation: patchResult,
identifier,
preferences: { type: { [defaultType]: 1 }},
});
});
it('does no conversion if there is no intermediate type.', async(): Promise<void> => {
convertingPatcher = new ConvertingPatcher(patcher, converter);
const result = await convertingPatcher.handle(args);
expect(result.metadata.contentType).toBe(patchResult.metadata.contentType);
// Patch
expect(converter.handle).toHaveBeenCalledTimes(0);
expect(patcher.handle).toHaveBeenCalledTimes(1);
expect(patcher.handle).toHaveBeenLastCalledWith(args);
});
it('does not convert to a default type if there is none.', async(): Promise<void> => {
delete args.representation;
convertingPatcher = new ConvertingPatcher(patcher, converter);
const result = await convertingPatcher.handle(args);
expect(result.metadata.contentType).toBe(patchResult.metadata.contentType);
// Patch
expect(converter.handle).toHaveBeenCalledTimes(0);
expect(patcher.handle).toHaveBeenCalledTimes(1);
expect(patcher.handle).toHaveBeenLastCalledWith(args);
});
});

View File

@ -0,0 +1,63 @@
import type { Patch } from '../../../../src/ldp/http/Patch';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import type { PatchHandlerInput } from '../../../../src/storage/patch/PatchHandler';
import type { RepresentationPatcher } from '../../../../src/storage/patch/RepresentationPatcher';
import { RepresentationPatchHandler } from '../../../../src/storage/patch/RepresentationPatchHandler';
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
describe('A RepresentationPatchHandler', (): void => {
const identifier = { path: 'http://test.com/foo' };
const representation = new BasicRepresentation('', 'text/turtle');
const patch: Patch = new BasicRepresentation('', 'application/sparql-update');
const patchResult = new BasicRepresentation('', 'application/trig');
let input: PatchHandlerInput;
let source: jest.Mocked<ResourceStore>;
let patcher: jest.Mocked<RepresentationPatcher>;
let handler: RepresentationPatchHandler;
beforeEach(async(): Promise<void> => {
source = {
getRepresentation: jest.fn().mockResolvedValue(representation),
setRepresentation: jest.fn().mockResolvedValue([ identifier ]),
} as any;
input = { source, identifier, patch };
patcher = {
handleSafe: jest.fn().mockResolvedValue(patchResult),
} as any;
handler = new RepresentationPatchHandler(patcher);
});
it('calls the patcher with the representation from the store.', async(): Promise<void> => {
await expect(handler.handle(input)).resolves.toEqual([ identifier ]);
expect(patcher.handleSafe).toHaveBeenCalledTimes(1);
expect(patcher.handleSafe).toHaveBeenLastCalledWith({ identifier, patch, representation });
expect(source.setRepresentation).toHaveBeenCalledTimes(1);
expect(source.setRepresentation).toHaveBeenLastCalledWith(identifier, patchResult);
});
it('calls the patcher with no representation if there is none.', async(): Promise<void> => {
source.getRepresentation.mockRejectedValueOnce(new NotFoundHttpError());
await expect(handler.handle(input)).resolves.toEqual([ identifier ]);
expect(patcher.handleSafe).toHaveBeenCalledTimes(1);
expect(patcher.handleSafe).toHaveBeenLastCalledWith({ identifier, patch });
expect(source.setRepresentation).toHaveBeenCalledTimes(1);
expect(source.setRepresentation).toHaveBeenLastCalledWith(identifier, patchResult);
});
it('errors if the store throws a non-404 error.', async(): Promise<void> => {
const error = new BadRequestHttpError();
source.getRepresentation.mockRejectedValueOnce(error);
await expect(handler.handle(input)).rejects.toThrow(error);
});
});

View File

@ -1,249 +0,0 @@
import 'jest-rdf';
import { namedNode, quad } from '@rdfjs/data-model';
import arrayifyStream from 'arrayify-stream';
import type { Quad } from 'rdf-js';
import { translate } from 'sparqlalgebrajs';
import type { SparqlUpdatePatch } from '../../../../src/ldp/http/SparqlUpdatePatch';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
import type { RepresentationConverterArgs,
RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter';
import { SparqlUpdatePatchHandler } from '../../../../src/storage/patch/SparqlUpdatePatchHandler';
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes';
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
import { guardedStreamFrom } from '../../../../src/util/StreamUtil';
describe('A SparqlUpdatePatchHandler', (): void => {
let converter: jest.Mocked<RepresentationConverter>;
let handler: SparqlUpdatePatchHandler;
let source: jest.Mocked<ResourceStore>;
let startQuads: Quad[];
const defaultType = 'internal/not-quads';
const identifier = { path: 'http://test.com/foo' };
const fulfilledDataInsert = 'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 . }';
beforeEach(async(): Promise<void> => {
startQuads = [ quad(
namedNode('http://test.com/startS1'),
namedNode('http://test.com/startP1'),
namedNode('http://test.com/startO1'),
), quad(
namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
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 any;
source = {
getRepresentation: jest.fn(async(): Promise<any> => new BasicRepresentation(startQuads, defaultType)),
setRepresentation: jest.fn(),
} as any;
handler = new SparqlUpdatePatchHandler(converter, defaultType);
});
async function basicChecks(quads: Quad[]): Promise<boolean> {
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: { [INTERNAL_QUADS]: 1 }},
});
expect(converter.handleSafe).toHaveBeenLastCalledWith({
representation: expect.objectContaining({ binary: false, metadata: expect.any(RepresentationMetadata) }),
identifier,
preferences: { type: { [defaultType]: 1 }},
});
expect(source.setRepresentation).toHaveBeenCalledTimes(1);
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(defaultType);
await expect(arrayifyStream(setParams[1].data)).resolves.toBeRdfIsomorphic(quads);
return true;
}
async function handle(query: string): Promise<void> {
const prefixedQuery = `prefix : <http://test.com/>\n${query}`;
await handler.handle({
source,
identifier,
patch: {
algebra: translate(prefixedQuery, { quads: true }),
data: guardedStreamFrom(prefixedQuery),
metadata: new RepresentationMetadata(),
binary: true,
} as SparqlUpdatePatch,
});
}
it('only accepts SPARQL updates.', async(): Promise<void> => {
const input = { source, identifier, patch: { algebra: {}} as SparqlUpdatePatch };
await expect(handler.canHandle(input)).resolves.toBeUndefined();
delete (input.patch as any).algebra;
await expect(handler.canHandle(input)).rejects.toThrow(NotImplementedHttpError);
});
it('handles NOP operations by not doing anything.', async(): Promise<void> => {
await handle('');
expect(source.getRepresentation).toHaveBeenCalledTimes(0);
expect(converter.handleSafe).toHaveBeenCalledTimes(0);
expect(source.setRepresentation).toHaveBeenCalledTimes(0);
});
it('handles INSERT DATA updates.', async(): Promise<void> => {
await handle(fulfilledDataInsert);
expect(await basicChecks([ ...startQuads,
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('handles DELETE DATA updates.', async(): Promise<void> => {
await handle('DELETE DATA { :startS1 :startP1 :startO1 }');
expect(await basicChecks(
[ quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')) ],
)).toBe(true);
});
it('handles DELETE WHERE updates with no variables.', async(): Promise<void> => {
const query = 'DELETE WHERE { :startS1 :startP1 :startO1 }';
await handle(query);
expect(await basicChecks(
[ quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')) ],
)).toBe(true);
});
it('handles DELETE WHERE updates with variables.', async(): Promise<void> => {
const query = 'DELETE WHERE { :startS1 :startP1 ?o }';
await handle(query);
expect(await basicChecks(
[ quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')) ],
)).toBe(true);
});
it('handles DELETE/INSERT updates with empty WHERE.', async(): Promise<void> => {
const query = 'DELETE { :startS1 :startP1 :startO1 } INSERT { :s1 :p1 :o1 . } WHERE {}';
await handle(query);
expect(await basicChecks([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
quad(namedNode('http://test.com/s1'),
namedNode('http://test.com/p1'),
namedNode('http://test.com/o1')),
])).toBe(true);
});
it('handles composite INSERT/DELETE updates.', async(): Promise<void> => {
const query = 'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 };' +
'DELETE WHERE { :s1 :p1 :o1 . :startS1 :startP1 :startO1 }';
await handle(query);
expect(await basicChecks([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
quad(namedNode('http://test.com/s2'),
namedNode('http://test.com/p2'),
namedNode('http://test.com/o2')),
])).toBe(true);
});
it('handles composite DELETE/INSERT updates.', async(): Promise<void> => {
const query = 'DELETE DATA { :s1 :p1 :o1 . :startS1 :startP1 :startO1 } ;' +
'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 }';
await handle(query);
expect(await basicChecks([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
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('rejects GRAPH inserts.', async(): Promise<void> => {
const query = 'INSERT DATA { GRAPH :graph { :s1 :p1 :o1 } }';
await expect(handle(query)).rejects.toThrow(NotImplementedHttpError);
});
it('rejects GRAPH deletes.', async(): Promise<void> => {
const query = 'DELETE DATA { GRAPH :graph { :s1 :p1 :o1 } }';
await expect(handle(query)).rejects.toThrow(NotImplementedHttpError);
});
it('rejects DELETE/INSERT updates with non-BGP WHERE.', async(): Promise<void> => {
const query = 'DELETE { :s1 :p1 :o1 } INSERT { :s1 :p1 :o1 } WHERE { ?s ?p ?o. FILTER (?o > 5) }';
await expect(handle(query)).rejects.toThrow(NotImplementedHttpError);
});
it('rejects INSERT WHERE updates with a UNION.', async(): Promise<void> => {
const query = 'INSERT { :s1 :p1 :o1 . } WHERE { { :s1 :p1 :o1 } UNION { :s1 :p1 :o2 } }';
await expect(handle(query)).rejects.toThrow(NotImplementedHttpError);
});
it('rejects non-DELETE/INSERT updates.', async(): Promise<void> => {
const query = 'MOVE DEFAULT TO GRAPH :newGraph';
await expect(handle(query)).rejects.toThrow(NotImplementedHttpError);
});
it('throws the error returned by the store if there is one.', async(): Promise<void> => {
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.mockRejectedValueOnce(new NotFoundHttpError());
const query = 'INSERT DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>. }';
await handle(query);
expect(converter.handleSafe).toHaveBeenCalledTimes(1);
expect(converter.handleSafe).toHaveBeenLastCalledWith({
representation: expect.objectContaining({ binary: false, metadata: expect.any(RepresentationMetadata) }),
identifier,
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.mock.calls[0];
expect(setParams[1].metadata.contentType).toEqual(defaultType);
await expect(arrayifyStream(setParams[1].data)).resolves.toBeRdfIsomorphic(quads);
});
it('defaults to text/turtle if no default type was set.', async(): Promise<void> => {
handler = new SparqlUpdatePatchHandler(converter);
startQuads = [];
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.mock.calls[0];
expect(setParams[1].metadata.contentType).toEqual('text/turtle');
});
});

View File

@ -0,0 +1,228 @@
import 'jest-rdf';
import { namedNode, quad } from '@rdfjs/data-model';
import arrayifyStream from 'arrayify-stream';
import type { Quad } from 'rdf-js';
import { translate } from 'sparqlalgebrajs';
import type { SparqlUpdatePatch } from '../../../../src/ldp/http/SparqlUpdatePatch';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import type { Representation } from '../../../../src/ldp/representation/Representation';
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
import type { RepresentationPatcherInput } from '../../../../src/storage/patch/RepresentationPatcher';
import { SparqlUpdatePatcher } from '../../../../src/storage/patch/SparqlUpdatePatcher';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
import { guardedStreamFrom } from '../../../../src/util/StreamUtil';
function getPatch(query: string): SparqlUpdatePatch {
const prefixedQuery = `prefix : <http://test.com/>\n${query}`;
return {
algebra: translate(prefixedQuery, { quads: true }),
data: guardedStreamFrom(prefixedQuery),
metadata: new RepresentationMetadata(),
binary: true,
};
}
describe('A SparqlUpdatePatcher', (): void => {
let patcher: SparqlUpdatePatcher;
let startQuads: Quad[];
let representation: Representation;
const identifier = { path: 'http://test.com/foo' };
const fulfilledDataInsert = 'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 . }';
beforeEach(async(): Promise<void> => {
startQuads = [ quad(
namedNode('http://test.com/startS1'),
namedNode('http://test.com/startP1'),
namedNode('http://test.com/startO1'),
), quad(
namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2'),
) ];
representation = new BasicRepresentation(startQuads, 'internal/quads');
patcher = new SparqlUpdatePatcher();
});
it('only accepts SPARQL updates.', async(): Promise<void> => {
const input = { identifier, patch: { algebra: {}} as SparqlUpdatePatch };
await expect(patcher.canHandle(input)).resolves.toBeUndefined();
delete (input.patch as any).algebra;
await expect(patcher.canHandle(input)).rejects.toThrow(NotImplementedHttpError);
});
it('handles NOP operations by not doing anything.', async(): Promise<void> => {
let patch = getPatch('');
let input: RepresentationPatcherInput = { identifier, patch, representation };
await expect(patcher.handle(input)).resolves.toBe(representation);
patch = getPatch('');
input = { identifier, patch };
const result = await patcher.handle(input);
expect(result.metadata.contentType).toBe('internal/quads');
await expect(arrayifyStream(result.data)).resolves.toEqual([]);
});
it('handles INSERT DATA updates.', async(): Promise<void> => {
const patch = getPatch(fulfilledDataInsert);
const input: RepresentationPatcherInput = { identifier, patch, representation };
const result = await patcher.handle(input);
expect(result.metadata.contentType).toBe('internal/quads');
const resultQuads = await arrayifyStream(result.data);
expect(resultQuads).toBeRdfIsomorphic([
...startQuads,
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')),
]);
});
it('handles DELETE DATA updates.', async(): Promise<void> => {
const patch = getPatch('DELETE DATA { :startS1 :startP1 :startO1 }');
const input: RepresentationPatcherInput = { identifier, patch, representation };
const result = await patcher.handle(input);
const resultQuads = await arrayifyStream(result.data);
expect(resultQuads).toBeRdfIsomorphic([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
]);
});
it('handles DELETE WHERE updates with no variables.', async(): Promise<void> => {
const patch = getPatch('DELETE WHERE { :startS1 :startP1 :startO1 }');
const input: RepresentationPatcherInput = { identifier, patch, representation };
const result = await patcher.handle(input);
const resultQuads = await arrayifyStream(result.data);
expect(resultQuads).toBeRdfIsomorphic([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
]);
});
it('handles DELETE WHERE updates with variables.', async(): Promise<void> => {
const patch = getPatch('DELETE WHERE { :startS1 :startP1 ?o }');
const input: RepresentationPatcherInput = { identifier, patch, representation };
const result = await patcher.handle(input);
const resultQuads = await arrayifyStream(result.data);
expect(resultQuads).toBeRdfIsomorphic([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
]);
});
it('handles DELETE/INSERT updates with empty WHERE.', async(): Promise<void> => {
const patch = getPatch('DELETE { :startS1 :startP1 :startO1 } INSERT { :s1 :p1 :o1 . } WHERE {}');
const input: RepresentationPatcherInput = { identifier, patch, representation };
const result = await patcher.handle(input);
const resultQuads = await arrayifyStream(result.data);
expect(resultQuads).toBeRdfIsomorphic([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
quad(namedNode('http://test.com/s1'),
namedNode('http://test.com/p1'),
namedNode('http://test.com/o1')),
]);
});
it('handles composite INSERT/DELETE updates.', async(): Promise<void> => {
const query = 'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 };' +
'DELETE WHERE { :s1 :p1 :o1 . :startS1 :startP1 :startO1 }';
const patch = getPatch(query);
const input: RepresentationPatcherInput = { identifier, patch, representation };
const result = await patcher.handle(input);
const resultQuads = await arrayifyStream(result.data);
expect(resultQuads).toBeRdfIsomorphic([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
quad(namedNode('http://test.com/s2'),
namedNode('http://test.com/p2'),
namedNode('http://test.com/o2')),
]);
});
it('handles composite DELETE/INSERT updates.', async(): Promise<void> => {
const query = 'DELETE DATA { :s1 :p1 :o1 . :startS1 :startP1 :startO1 } ;' +
'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 }';
const patch = getPatch(query);
const input: RepresentationPatcherInput = { identifier, patch, representation };
const result = await patcher.handle(input);
const resultQuads = await arrayifyStream(result.data);
expect(resultQuads).toBeRdfIsomorphic([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
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')),
]);
});
it('rejects GRAPH inserts.', async(): Promise<void> => {
const query = 'INSERT DATA { GRAPH :graph { :s1 :p1 :o1 } }';
const patch = getPatch(query);
const input: RepresentationPatcherInput = { identifier, patch, representation };
await expect(patcher.handle(input)).rejects.toThrow(NotImplementedHttpError);
});
it('rejects GRAPH deletes.', async(): Promise<void> => {
const query = 'DELETE DATA { GRAPH :graph { :s1 :p1 :o1 } }';
const patch = getPatch(query);
const input: RepresentationPatcherInput = { identifier, patch, representation };
await expect(patcher.handle(input)).rejects.toThrow(NotImplementedHttpError);
});
it('rejects DELETE/INSERT updates with non-BGP WHERE.', async(): Promise<void> => {
const query = 'DELETE { :s1 :p1 :o1 } INSERT { :s1 :p1 :o1 } WHERE { ?s ?p ?o. FILTER (?o > 5) }';
const patch = getPatch(query);
const input: RepresentationPatcherInput = { identifier, patch, representation };
await expect(patcher.handle(input)).rejects.toThrow(NotImplementedHttpError);
});
it('rejects INSERT WHERE updates with a UNION.', async(): Promise<void> => {
const query = 'INSERT { :s1 :p1 :o1 . } WHERE { { :s1 :p1 :o1 } UNION { :s1 :p1 :o2 } }';
const patch = getPatch(query);
const input: RepresentationPatcherInput = { identifier, patch, representation };
await expect(patcher.handle(input)).rejects.toThrow(NotImplementedHttpError);
});
it('rejects non-DELETE/INSERT updates.', async(): Promise<void> => {
const query = 'MOVE DEFAULT TO GRAPH :newGraph';
const patch = getPatch(query);
const input: RepresentationPatcherInput = { identifier, patch, representation };
await expect(patcher.handle(input)).rejects.toThrow(NotImplementedHttpError);
});
it('creates a new resource if it does not exist yet.', async(): Promise<void> => {
const query = 'INSERT DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>. }';
const patch = getPatch(query);
const input: RepresentationPatcherInput = { identifier, patch };
const result = await patcher.handle(input);
expect(result.metadata.contentType).toBe('internal/quads');
expect(result.metadata.identifier.value).toBe(identifier.path);
const resultQuads = await arrayifyStream(result.data);
expect(resultQuads).toBeRdfIsomorphic([
quad(namedNode('http://test.com/s1'), namedNode('http://test.com/p1'), namedNode('http://test.com/o1')),
]);
});
it('requires the input body to contain quads.', async(): Promise<void> => {
const query = 'INSERT DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>. }';
const patch = getPatch(query);
representation.metadata.contentType = 'text/turtle';
const input = { identifier, patch, representation };
await expect(patcher.handle(input)).rejects.toThrow('Quad stream was expected for patching.');
});
});