mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
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:
parent
a1c3633a25
commit
ef9703e284
@ -5,10 +5,26 @@
|
|||||||
"comment": "Allows for PATCH operations on stores that don't have native support.",
|
"comment": "Allows for PATCH operations on stores that don't have native support.",
|
||||||
"@id": "urn:solid-server:default:ResourceStore_Patching",
|
"@id": "urn:solid-server:default:ResourceStore_Patching",
|
||||||
"@type": "PatchingStore",
|
"@type": "PatchingStore",
|
||||||
"patcher": {
|
"patchHandler": {
|
||||||
"@id": "urn:solid-server:default:PatchHandler",
|
"@id": "urn:solid-server:default:PatchHandler",
|
||||||
"@type": "SparqlUpdatePatchHandler",
|
"@type": "RepresentationPatchHandler",
|
||||||
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" }
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -259,9 +259,12 @@ export * from './storage/mapping/FixedContentTypeMapper';
|
|||||||
export * from './storage/mapping/SubdomainExtensionBasedMapper';
|
export * from './storage/mapping/SubdomainExtensionBasedMapper';
|
||||||
|
|
||||||
// Storage/Patch
|
// 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/PatchHandler';
|
||||||
export * from './storage/patch/SparqlUpdatePatchHandler';
|
export * from './storage/patch/RepresentationPatcher';
|
||||||
|
export * from './storage/patch/RepresentationPatchHandler';
|
||||||
|
export * from './storage/patch/SparqlUpdatePatcher';
|
||||||
|
|
||||||
// Storage/Routing
|
// Storage/Routing
|
||||||
export * from './storage/routing/BaseUrlRouterRule';
|
export * from './storage/routing/BaseUrlRouterRule';
|
||||||
|
@ -12,11 +12,11 @@ import type { ResourceStore } from './ResourceStore';
|
|||||||
* otherwise the {@link PatchHandler} will be called instead.
|
* otherwise the {@link PatchHandler} will be called instead.
|
||||||
*/
|
*/
|
||||||
export class PatchingStore<T extends ResourceStore = ResourceStore> extends PassthroughStore<T> {
|
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);
|
super(source);
|
||||||
this.patcher = patcher;
|
this.patchHandler = patchHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async modifyResource(identifier: ResourceIdentifier, patch: Patch,
|
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);
|
return await this.source.modifyResource(identifier, patch, conditions);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (NotImplementedHttpError.isInstance(error)) {
|
if (NotImplementedHttpError.isInstance(error)) {
|
||||||
return this.patcher.handleSafe({ source: this.source, identifier, patch });
|
return this.patchHandler.handleSafe({ source: this.source, identifier, patch });
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
51
src/storage/patch/ContainerPatcher.ts
Normal file
51
src/storage/patch/ContainerPatcher.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
@ -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>;
|
|
||||||
}
|
|
79
src/storage/patch/ConvertingPatcher.ts
Normal file
79
src/storage/patch/ConvertingPatcher.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -3,7 +3,7 @@ import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdenti
|
|||||||
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
|
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
|
||||||
import type { ResourceStore } from '../ResourceStore';
|
import type { ResourceStore } from '../ResourceStore';
|
||||||
|
|
||||||
export type PatchHandlerArgs<T extends ResourceStore = ResourceStore> = {
|
export type PatchHandlerInput<T extends ResourceStore = ResourceStore> = {
|
||||||
source: T;
|
source: T;
|
||||||
identifier: ResourceIdentifier;
|
identifier: ResourceIdentifier;
|
||||||
patch: Patch;
|
patch: Patch;
|
||||||
@ -13,4 +13,4 @@ export type PatchHandlerArgs<T extends ResourceStore = ResourceStore> = {
|
|||||||
* Executes the given Patch.
|
* Executes the given Patch.
|
||||||
*/
|
*/
|
||||||
export abstract class PatchHandler<T extends ResourceStore = ResourceStore>
|
export abstract class PatchHandler<T extends ResourceStore = ResourceStore>
|
||||||
extends AsyncHandler<PatchHandlerArgs<T>, ResourceIdentifier[]> {}
|
extends AsyncHandler<PatchHandlerInput<T>, ResourceIdentifier[]> {}
|
||||||
|
47
src/storage/patch/RepresentationPatchHandler.ts
Normal file
47
src/storage/patch/RepresentationPatchHandler.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
15
src/storage/patch/RepresentationPatcher.ts
Normal file
15
src/storage/patch/RepresentationPatcher.ts
Normal 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> {}
|
@ -11,50 +11,48 @@ import type { SparqlUpdatePatch } from '../../ldp/http/SparqlUpdatePatch';
|
|||||||
import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation';
|
import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation';
|
||||||
import type { Representation } from '../../ldp/representation/Representation';
|
import type { Representation } from '../../ldp/representation/Representation';
|
||||||
import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
|
import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
|
||||||
import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
|
|
||||||
import { getLoggerFor } from '../../logging/LogUtil';
|
import { getLoggerFor } from '../../logging/LogUtil';
|
||||||
import { INTERNAL_QUADS } from '../../util/ContentTypes';
|
import { INTERNAL_QUADS } from '../../util/ContentTypes';
|
||||||
|
import { InternalServerError } from '../../util/errors/InternalServerError';
|
||||||
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
|
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
|
||||||
import { readableToQuads, readableToString } from '../../util/StreamUtil';
|
import { readableToQuads, readableToString } from '../../util/StreamUtil';
|
||||||
import type { RepresentationConverter } from '../conversion/RepresentationConverter';
|
import { RepresentationPatcher } from './RepresentationPatcher';
|
||||||
import { ConvertingPatchHandler } from './ConvertingPatchHandler';
|
import type { RepresentationPatcherInput } from './RepresentationPatcher';
|
||||||
import type { PatchHandlerArgs } from './PatchHandler';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Supports application/sparql-update PATCH requests on RDF resources.
|
* Supports application/sparql-update PATCH requests on RDF resources.
|
||||||
*
|
*
|
||||||
* Only DELETE/INSERT updates without variables are supported.
|
* Only DELETE/INSERT updates without variables are supported.
|
||||||
*/
|
*/
|
||||||
export class SparqlUpdatePatchHandler extends ConvertingPatchHandler {
|
export class SparqlUpdatePatcher extends RepresentationPatcher {
|
||||||
protected readonly logger = getLoggerFor(this);
|
protected readonly logger = getLoggerFor(this);
|
||||||
|
|
||||||
private readonly engine: ActorInitSparql;
|
private readonly engine: ActorInitSparql;
|
||||||
|
|
||||||
public constructor(converter: RepresentationConverter, defaultType = 'text/turtle') {
|
public constructor() {
|
||||||
super(converter, INTERNAL_QUADS, defaultType);
|
super();
|
||||||
this.engine = newEngine();
|
this.engine = newEngine();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async canHandle({ patch }: PatchHandlerArgs): Promise<void> {
|
public async canHandle({ patch }: RepresentationPatcherInput): Promise<void> {
|
||||||
if (!this.isSparqlUpdate(patch)) {
|
if (!this.isSparqlUpdate(patch)) {
|
||||||
throw new NotImplementedHttpError('Only SPARQL update patches are supported');
|
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
|
// Verify the patch
|
||||||
const { patch } = input;
|
const { patch, representation, identifier } = input;
|
||||||
const op = (patch as SparqlUpdatePatch).algebra;
|
const op = (patch as SparqlUpdatePatch).algebra;
|
||||||
|
|
||||||
// In case of a NOP we can skip everything
|
// In case of a NOP we can skip everything
|
||||||
if (op.type === Algebra.types.NOP) {
|
if (op.type === Algebra.types.NOP) {
|
||||||
return [];
|
return representation ?? new BasicRepresentation([], identifier, INTERNAL_QUADS, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.validateUpdate(op);
|
this.validateUpdate(op);
|
||||||
|
|
||||||
// Only start conversion if we know the operation is valid
|
return this.patch(input);
|
||||||
return super.handle(input);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private isSparqlUpdate(patch: Patch): patch is SparqlUpdatePatch {
|
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.
|
* Apply the given algebra operation to the given identifier.
|
||||||
*/
|
*/
|
||||||
protected async patch(input: PatchHandlerArgs, representation?: Representation): Promise<Representation> {
|
private async patch({ identifier, patch, representation }: RepresentationPatcherInput): Promise<Representation> {
|
||||||
const { identifier, patch } = input;
|
|
||||||
let result: Store<BaseQuad>;
|
let result: Store<BaseQuad>;
|
||||||
let metadata: RepresentationMetadata;
|
let metadata: RepresentationMetadata;
|
||||||
|
|
||||||
if (representation) {
|
if (representation) {
|
||||||
({ metadata } = representation);
|
({ metadata } = representation);
|
||||||
|
if (metadata.contentType !== INTERNAL_QUADS) {
|
||||||
|
throw new InternalServerError('Quad stream was expected for patching.');
|
||||||
|
}
|
||||||
result = await readableToQuads(representation.data);
|
result = await readableToQuads(representation.data);
|
||||||
this.logger.debug(`${result.size} quads in ${identifier.path}.`);
|
this.logger.debug(`${result.size} quads in ${identifier.path}.`);
|
||||||
} else {
|
} else {
|
||||||
@ -139,6 +139,6 @@ export class SparqlUpdatePatchHandler extends ConvertingPatchHandler {
|
|||||||
|
|
||||||
this.logger.debug(`${result.size} quads will be stored to ${identifier.path}.`);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -358,4 +358,40 @@ describe.each(stores)('An LDP handler allowing all requests %s', (name, { storeC
|
|||||||
// DELETE
|
// DELETE
|
||||||
expect(await deleteResource(documentUrl)).toBeUndefined();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -174,4 +174,23 @@ describe('A Solid server', (): void => {
|
|||||||
});
|
});
|
||||||
expect(res.status).toBe(205);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
91
test/unit/storage/patch/ContainerPatcher.test.ts
Normal file
91
test/unit/storage/patch/ContainerPatcher.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
129
test/unit/storage/patch/ConvertingPatcher.test.ts
Normal file
129
test/unit/storage/patch/ConvertingPatcher.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
63
test/unit/storage/patch/RepresentationPatchHandler.test.ts
Normal file
63
test/unit/storage/patch/RepresentationPatchHandler.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
228
test/unit/storage/patch/SparqlUpdatePatcher.test.ts
Normal file
228
test/unit/storage/patch/SparqlUpdatePatcher.test.ts
Normal 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.');
|
||||||
|
});
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user