feat: Add support for N3 Patch

This commit is contained in:
Joachim Van Herwegen 2022-01-20 10:48:47 +01:00
parent 1afed65368
commit a9941ebe78
28 changed files with 1331 additions and 46 deletions

View File

@ -6,6 +6,7 @@
- The `VoidLocker` can be used to disable locking for development/testing purposes. This can be enabled by changing the `/config/util/resource-locker/` import to `debug-void.json`
- Added support for setting a quota on the server. See the `config/quota-file.json` config for an example.
- An official docker image is now built on each version tag and published at https://hub.docker.com/r/solidproject/community-server.
- Added support for N3 Patch.
### Configuration changes
You might need to make changes to your v2 configuration if you use a custom config.

View File

@ -16,10 +16,23 @@
"args_bodyParser": {
"@type": "WaterfallHandler",
"handlers": [
{ "@type": "SparqlUpdateBodyParser" },
{ "@id": "urn:solid-server:default:PatchBodyParser" },
{ "@type": "RawBodyParser" }
]
}
},
{
"comment": "Handles body parsing for PATCH requests. Those requests need to generate an interpreted Patch body.",
"@id": "urn:solid-server:default:PatchBodyParser",
"@type": "MethodFilterHandler",
"methods": [ "PATCH" ],
"source": {
"@type": "WaterfallHandler",
"handlers": [
{ "@type": "N3PatchBodyParser" },
{ "@type": "SparqlUpdateBodyParser" }
]
}
}
]
}

View File

@ -8,7 +8,7 @@
"headers": [
{
"ConstantMetadataWriter:_headers_key": "Accept-Patch",
"ConstantMetadataWriter:_headers_value": "application/sparql-update"
"ConstantMetadataWriter:_headers_value": "application/sparql-update, text/n3"
},
{
"ConstantMetadataWriter:_headers_key": "Allow",

View File

@ -27,6 +27,7 @@
"source": {
"@type": "WaterfallHandler",
"handlers": [
{ "@type": "N3PatchModesExtractor" },
{ "@type": "SparqlUpdateModesExtractor" },
{
"@type": "StaticThrowHandler",

View File

@ -14,11 +14,11 @@
{
"comment": "Makes sure PATCH operations on containers target the metadata.",
"@type": "ContainerPatcher",
"patcher": { "@type": "SparqlUpdatePatcher" }
"patcher": { "@id": "urn:solid-server:default:PatchHandler_RDF" }
},
{
"@type": "ConvertingPatcher",
"patcher": { "@type": "SparqlUpdatePatcher" },
"patcher": { "@id": "urn:solid-server:default:PatchHandler_RDF" },
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
"intermediateType": "internal/quads",
"defaultType": "text/turtle"
@ -30,6 +30,15 @@
]
}
}
},
{
"comment": "Dedicated handlers that apply specific types of patch documents",
"@id": "urn:solid-server:default:PatchHandler_RDF",
"@type": "WaterfallHandler",
"handlers": [
{ "@type": "N3Patcher" },
{ "@type": "SparqlUpdatePatcher" }
]
}
]
}

31
package-lock.json generated
View File

@ -54,6 +54,7 @@
"punycode": "^2.1.1",
"rdf-parse": "^1.8.1",
"rdf-serialize": "^1.1.0",
"rdf-terms": "^1.7.1",
"redis": "^3.1.2",
"redlock": "^4.2.0",
"sparqlalgebrajs": "^4.0.1",
@ -11360,11 +11361,6 @@
"integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=",
"dev": true
},
"node_modules/lodash.uniqwith": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.uniqwith/-/lodash.uniqwith-4.5.0.tgz",
"integrity": "sha1-egy/ZfQ7WShiWp1NDcVLGMrcfvM="
},
"node_modules/logform": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.2.0.tgz",
@ -13542,13 +13538,13 @@
}
},
"node_modules/rdf-terms": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.7.0.tgz",
"integrity": "sha512-K83ACD+MuWFS3mNxwCRNYQAmc/Z9iK7PgqJq9N4VP8sUVlP7ioB2pPNQHKHy0IQh4RTkEq6fg4R4q7YlweLBZQ==",
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.7.1.tgz",
"integrity": "sha512-zhYKqTrXTsoybs05Dpu1b+FDnS3+RsU4Fxsqj5aG7frPXDx0MMnIQOKUKpJL7KKYOtq/JE5JsLup6lggnxPqig==",
"dependencies": {
"@rdfjs/types": "*",
"lodash.uniqwith": "^4.5.0",
"rdf-data-factory": "^1.1.0"
"rdf-data-factory": "^1.1.0",
"rdf-string": "^1.6.0"
}
},
"node_modules/rdfa-streaming-parser": {
@ -24527,11 +24523,6 @@
"integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=",
"dev": true
},
"lodash.uniqwith": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.uniqwith/-/lodash.uniqwith-4.5.0.tgz",
"integrity": "sha1-egy/ZfQ7WShiWp1NDcVLGMrcfvM="
},
"logform": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.2.0.tgz",
@ -26206,13 +26197,13 @@
}
},
"rdf-terms": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.7.0.tgz",
"integrity": "sha512-K83ACD+MuWFS3mNxwCRNYQAmc/Z9iK7PgqJq9N4VP8sUVlP7ioB2pPNQHKHy0IQh4RTkEq6fg4R4q7YlweLBZQ==",
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.7.1.tgz",
"integrity": "sha512-zhYKqTrXTsoybs05Dpu1b+FDnS3+RsU4Fxsqj5aG7frPXDx0MMnIQOKUKpJL7KKYOtq/JE5JsLup6lggnxPqig==",
"requires": {
"@rdfjs/types": "*",
"lodash.uniqwith": "^4.5.0",
"rdf-data-factory": "^1.1.0"
"rdf-data-factory": "^1.1.0",
"rdf-string": "^1.6.0"
}
},
"rdfa-streaming-parser": {

View File

@ -120,6 +120,7 @@
"punycode": "^2.1.1",
"rdf-parse": "^1.8.1",
"rdf-serialize": "^1.1.0",
"rdf-terms": "^1.7.1",
"redis": "^3.1.2",
"redlock": "^4.2.0",
"sparqlalgebrajs": "^4.0.1",

View File

@ -0,0 +1,44 @@
import type { Operation } from '../../http/Operation';
import type { N3Patch } from '../../http/representation/N3Patch';
import { isN3Patch } from '../../http/representation/N3Patch';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { ModesExtractor } from './ModesExtractor';
import { AccessMode } from './Permissions';
/**
* Extracts the required access modes from an N3 Patch.
*
* Solid, §5.3.1: "When ?conditions is non-empty, servers MUST treat the request as a Read operation.
* When ?insertions is non-empty, servers MUST (also) treat the request as an Append operation.
* When ?deletions is non-empty, servers MUST treat the request as a Read and Write operation."
* https://solid.github.io/specification/protocol#n3-patch
*/
export class N3PatchModesExtractor extends ModesExtractor {
public async canHandle({ body }: Operation): Promise<void> {
if (!isN3Patch(body)) {
throw new NotImplementedHttpError('Can only determine permissions of N3 Patch documents.');
}
}
public async handle({ body }: Operation): Promise<Set<AccessMode>> {
const { deletes, inserts, conditions } = body as N3Patch;
const accessModes = new Set<AccessMode>();
// When ?conditions is non-empty, servers MUST treat the request as a Read operation.
if (conditions.length > 0) {
accessModes.add(AccessMode.read);
}
// When ?insertions is non-empty, servers MUST (also) treat the request as an Append operation.
if (inserts.length > 0) {
accessModes.add(AccessMode.append);
}
// When ?deletions is non-empty, servers MUST treat the request as a Read and Write operation.
if (deletes.length > 0) {
accessModes.add(AccessMode.read);
accessModes.add(AccessMode.write);
}
return accessModes;
}
}

View File

@ -0,0 +1,138 @@
import type { NamedNode, Quad, Quad_Subject, Variable } from '@rdfjs/types';
import { DataFactory, Parser, Store } from 'n3';
import { getBlankNodes, getTerms, getVariables } from 'rdf-terms';
import { TEXT_N3 } from '../../../util/ContentTypes';
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
import { createErrorMessage } from '../../../util/errors/ErrorUtil';
import { UnprocessableEntityHttpError } from '../../../util/errors/UnprocessableEntityHttpError';
import { UnsupportedMediaTypeHttpError } from '../../../util/errors/UnsupportedMediaTypeHttpError';
import { guardedStreamFrom, readableToString } from '../../../util/StreamUtil';
import { RDF, SOLID } from '../../../util/Vocabularies';
import type { N3Patch } from '../../representation/N3Patch';
import type { BodyParserArgs } from './BodyParser';
import { BodyParser } from './BodyParser';
const defaultGraph = DataFactory.defaultGraph();
/**
* Parses an N3 Patch document and makes sure it conforms to the specification requirements.
* Requirements can be found at Solid Protocol, §5.3.1: https://solid.github.io/specification/protocol#n3-patch
*/
export class N3PatchBodyParser extends BodyParser {
public async canHandle({ metadata }: BodyParserArgs): Promise<void> {
if (metadata.contentType !== TEXT_N3) {
throw new UnsupportedMediaTypeHttpError('This parser only supports N3 Patch documents.');
}
}
public async handle({ request, metadata }: BodyParserArgs): Promise<N3Patch> {
const n3 = await readableToString(request);
const parser = new Parser({ format: TEXT_N3, baseIRI: metadata.identifier.value });
let store: Store;
try {
store = new Store(parser.parse(n3));
} catch (error: unknown) {
throw new BadRequestHttpError(`Invalid N3: ${createErrorMessage(error)}`);
}
// Solid, §5.3.1: "A patch resource MUST contain a triple ?patch rdf:type solid:InsertDeletePatch."
// "The patch document MUST contain exactly one patch resource,
// identified by one or more of the triple patterns described above, which all share the same ?patch subject."
const patches = store.getSubjects(RDF.terms.type, SOLID.terms.InsertDeletePatch, defaultGraph);
if (patches.length !== 1) {
throw new UnprocessableEntityHttpError(
`This patcher only supports N3 Patch documents with exactly 1 solid:InsertDeletePatch entry, but received ${
patches.length}.`,
);
}
return {
...this.parsePatch(patches[0], store),
binary: true,
data: guardedStreamFrom(n3),
metadata,
isEmpty: false,
};
}
/**
* Extracts the deletes/inserts/conditions from a solid:InsertDeletePatch entry.
*/
private parsePatch(patch: Quad_Subject, store: Store): { deletes: Quad[]; inserts: Quad[]; conditions: Quad[] } {
// Solid, §5.3.1: "A patch resource MUST be identified by a URI or blank node, which we refer to as ?patch
// in the remainder of this section."
if (patch.termType !== 'NamedNode' && patch.termType !== 'BlankNode') {
throw new UnprocessableEntityHttpError('An N3 Patch subject needs to be a blank or named node.');
}
// Extract all quads from the corresponding formulae
const deletes = this.findQuads(store, patch, SOLID.terms.deletes);
const inserts = this.findQuads(store, patch, SOLID.terms.inserts);
const conditions = this.findQuads(store, patch, SOLID.terms.where);
// Make sure there are no forbidden combinations
const conditionVars = this.findVariables(conditions);
this.verifyQuads(deletes, conditionVars);
this.verifyQuads(inserts, conditionVars);
return { deletes, inserts, conditions };
}
/**
* Finds all quads in a where/deletes/inserts formula.
* The returned quads will be updated so their graph is the default graph instead of the N3 reference to the formula.
* Will error in case there are multiple instances of the subject/predicate combination.
*/
private findQuads(store: Store, subject: Quad_Subject, predicate: NamedNode): Quad[] {
const graphs = store.getObjects(subject, predicate, defaultGraph);
if (graphs.length > 1) {
throw new UnprocessableEntityHttpError(`An N3 Patch can have at most 1 ${predicate.value}.`);
}
if (graphs.length === 0) {
return [];
}
// This might not return all quads in case of nested formulae,
// but these are not allowed and will throw an error later when checking for blank nodes.
// Another check would be needed in case blank nodes are allowed in the future.
const quads: Quad[] = store.getQuads(null, null, null, graphs[0]);
// Remove the graph references so they can be interpreted as standard triples
// independent of the formula they were in.
return quads.map((quad): Quad => DataFactory.quad(quad.subject, quad.predicate, quad.object, defaultGraph));
}
/**
* Finds all variables in a set of quads.
*/
private findVariables(quads: Quad[]): Set<string> {
return new Set(
quads.flatMap((quad): Variable[] => getVariables(getTerms(quad)))
.map((variable): string => variable.value),
);
}
/**
* Verifies if the delete/insert triples conform to the specification requirements:
* - They should not contain blank nodes.
* - They should not contain variables that do not occur in the conditions.
*/
private verifyQuads(otherQuads: Quad[], conditionVars: Set<string>): void {
for (const quad of otherQuads) {
const terms = getTerms(quad);
const blankNodes = getBlankNodes(terms);
// Solid, §5.3.1: "The ?insertions and ?deletions formulae MUST NOT contain blank nodes."
if (blankNodes.length > 0) {
throw new UnprocessableEntityHttpError(`An N3 Patch delete/insert formula can not contain blank nodes.`);
}
const variables = getVariables(terms);
for (const variable of variables) {
// Solid, §5.3.1: "The ?insertions and ?deletions formulae
// MUST NOT contain variables that do not occur in the ?conditions formula."
if (!conditionVars.has(variable.value)) {
throw new UnprocessableEntityHttpError(
`An N3 Patch delete/insert formula can only contain variables found in the conditions formula.`,
);
}
}
}
}
}

View File

@ -0,0 +1,18 @@
import type { Quad } from 'rdf-js';
import type { Patch } from './Patch';
/**
* A Representation of an N3 Patch.
* All quads should be in the default graph.
*/
export interface N3Patch extends Patch {
deletes: Quad[];
inserts: Quad[];
conditions: Quad[];
}
export function isN3Patch(patch: unknown): patch is N3Patch {
return Array.isArray((patch as N3Patch).deletes) &&
Array.isArray((patch as N3Patch).inserts) &&
Array.isArray((patch as N3Patch).conditions);
}

View File

@ -18,6 +18,7 @@ export * from './authorization/access/AgentGroupAccessChecker';
export * from './authorization/permissions/Permissions';
export * from './authorization/permissions/ModesExtractor';
export * from './authorization/permissions/MethodModesExtractor';
export * from './authorization/permissions/N3PatchModesExtractor';
export * from './authorization/permissions/SparqlUpdateModesExtractor';
// Authorization
@ -45,6 +46,7 @@ export * from './http/auxiliary/Validator';
// HTTP/Input/Body
export * from './http/input/body/BodyParser';
export * from './http/input/body/N3PatchBodyParser';
export * from './http/input/body/RawBodyParser';
export * from './http/input/body/SparqlUpdateBodyParser';
@ -295,6 +297,7 @@ export * from './storage/mapping/SubdomainExtensionBasedMapper';
// Storage/Patch
export * from './storage/patch/ContainerPatcher';
export * from './storage/patch/ConvertingPatcher';
export * from './storage/patch/N3Patcher';
export * from './storage/patch/PatchHandler';
export * from './storage/patch/RepresentationPatcher';
export * from './storage/patch/RepresentationPatchHandler';

View File

@ -0,0 +1,160 @@
import type { Readable } from 'stream';
import { newEngine } from '@comunica/actor-init-sparql';
import type { ActorInitSparql } from '@comunica/actor-init-sparql';
import type { IQueryResultBindings } from '@comunica/actor-init-sparql/lib/ActorInitSparql-browser';
import { Store } from 'n3';
import type { Quad, Term } from 'rdf-js';
import { mapTerms } from 'rdf-terms';
import { Generator, Wildcard } from 'sparqljs';
import type { SparqlGenerator } from 'sparqljs';
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import { isN3Patch } from '../../http/representation/N3Patch';
import type { N3Patch } from '../../http/representation/N3Patch';
import type { Representation } from '../../http/representation/Representation';
import { RepresentationMetadata } from '../../http/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import { getLoggerFor } from '../../logging/LogUtil';
import { INTERNAL_QUADS } from '../../util/ContentTypes';
import { ConflictHttpError } from '../../util/errors/ConflictHttpError';
import { InternalServerError } from '../../util/errors/InternalServerError';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { uniqueQuads } from '../../util/QuadUtil';
import { readableToQuads } from '../../util/StreamUtil';
import type { RepresentationPatcherInput } from './RepresentationPatcher';
import { RepresentationPatcher } from './RepresentationPatcher';
/**
* Applies an N3 Patch to a representation, or creates a new one if required.
* Follows all the steps from Solid, §5.3.1: https://solid.github.io/specification/protocol#n3-patch
*/
export class N3Patcher extends RepresentationPatcher {
protected readonly logger = getLoggerFor(this);
private readonly engine: ActorInitSparql;
private readonly generator: SparqlGenerator;
public constructor() {
super();
this.engine = newEngine();
this.generator = new Generator();
}
public async canHandle({ patch }: RepresentationPatcherInput): Promise<void> {
if (!isN3Patch(patch)) {
throw new NotImplementedHttpError('Only N3 Patch updates are supported');
}
}
public async handle(input: RepresentationPatcherInput): Promise<Representation> {
const patch = input.patch as N3Patch;
// No work to be done if the patch is empty
if (patch.deletes.length === 0 && patch.inserts.length === 0 && patch.conditions.length === 0) {
this.logger.debug('Empty patch, returning input.');
return input.representation ?? new BasicRepresentation([], input.identifier, INTERNAL_QUADS, false);
}
if (input.representation && input.representation.metadata.contentType !== INTERNAL_QUADS) {
this.logger.error('Received non-quad data. This should not happen so there is probably a configuration error.');
throw new InternalServerError('Quad stream was expected for patching.');
}
return this.patch(input);
}
/**
* Applies the given N3Patch to the representation.
* First the conditions are applied to find the necessary bindings,
* which are then applied to generate the triples that need to be deleted and inserted.
* After that the delete and insert operations are applied.
*/
private async patch({ identifier, patch, representation }: RepresentationPatcherInput): Promise<Representation> {
const result = representation ? await readableToQuads(representation.data) : new Store();
this.logger.debug(`${result.size} quads in ${identifier.path}.`);
const { deletes, inserts } = await this.applyConditions(patch as N3Patch, identifier, result);
// Apply deletes
if (deletes.length > 0) {
// There could potentially be duplicates after applying conditions,
// which would result in an incorrect count.
const uniqueDeletes = uniqueQuads(deletes);
// Solid, §5.3.1: "The triples resulting from ?deletions are to be removed from the RDF dataset."
const oldSize = result.size;
result.removeQuads(uniqueDeletes);
// Solid, §5.3.1: "If the set of triples resulting from ?deletions is non-empty and the dataset
// does not contain all of these triples, the server MUST respond with a 409 status code."
if (oldSize - result.size !== uniqueDeletes.length) {
throw new ConflictHttpError(
'The document does not contain all triples the N3 Patch requests to delete, which is required for patching.',
);
}
this.logger.debug(`Deleted ${oldSize - result.size} quads from ${identifier.path}.`);
}
// Solid, §5.3.1: "The triples resulting from ?insertions are to be added to the RDF dataset,
// with each blank node from ?insertions resulting in a newly created blank node."
result.addQuads(inserts);
this.logger.debug(`${result.size} total quads after patching ${identifier.path}.`);
const metadata = representation?.metadata ?? new RepresentationMetadata(identifier, INTERNAL_QUADS);
return new BasicRepresentation(result.match() as unknown as Readable, metadata, false);
}
/**
* Creates a new N3Patch where the conditions of the provided patch parameter are applied to its deletes and inserts.
* Also does the necessary checks to make sure the conditions are valid for the given dataset.
*/
private async applyConditions(patch: N3Patch, identifier: ResourceIdentifier, source: Store): Promise<N3Patch> {
const { conditions } = patch;
let { deletes, inserts } = patch;
if (conditions.length > 0) {
// Solid, §5.3.1: "If ?conditions is non-empty, find all (possibly empty) variable mappings
// such that all of the resulting triples occur in the dataset."
const sparql = this.generator.stringify({
type: 'query',
queryType: 'SELECT',
variables: [ new Wildcard() ],
prefixes: {},
where: [{
type: 'bgp',
triples: conditions,
}],
});
this.logger.debug(`Finding bindings using SPARQL query ${sparql}`);
const query = await this.engine.query(sparql,
{ sources: [ source ], baseIRI: identifier.path }) as IQueryResultBindings;
const bindings = await query.bindings();
// Solid, §5.3.1: "If no such mapping exists, or if multiple mappings exist,
// the server MUST respond with a 409 status code."
if (bindings.length === 0) {
throw new ConflictHttpError(
'The document does not contain any matches for the N3 Patch solid:where condition.',
);
}
if (bindings.length > 1) {
throw new ConflictHttpError(
'The document contains multiple matches for the N3 Patch solid:where condition, which is not allowed.',
);
}
// Apply bindings to deletes/inserts
// Note that Comunica binding keys start with a `?` while Variable terms omit that in their value
deletes = deletes.map((quad): Quad => mapTerms<Quad>(quad, (term): Term =>
term.termType === 'Variable' ? bindings[0].get(`?${term.value}`) : term));
inserts = inserts.map((quad): Quad => mapTerms<Quad>(quad, (term): Term =>
term.termType === 'Variable' ? bindings[0].get(`?${term.value}`) : term));
}
return {
...patch,
deletes,
inserts,
conditions: [],
};
}
}

View File

@ -4,7 +4,6 @@ import { newEngine } from '@comunica/actor-init-sparql';
import type { IQueryResultUpdate } from '@comunica/actor-init-sparql/lib/ActorInitSparql-browser';
import { defaultGraph } from '@rdfjs/data-model';
import { Store } from 'n3';
import type { BaseQuad } from 'rdf-js';
import { Algebra } from 'sparqlalgebrajs';
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import type { Patch } from '../../http/representation/Patch';
@ -50,6 +49,10 @@ export class SparqlUpdatePatcher extends RepresentationPatcher {
return representation ?? new BasicRepresentation([], identifier, INTERNAL_QUADS, false);
}
if (representation && representation.metadata.contentType !== INTERNAL_QUADS) {
throw new InternalServerError('Quad stream was expected for patching.');
}
this.validateUpdate(op);
return this.patch(input);
@ -116,20 +119,8 @@ export class SparqlUpdatePatcher extends RepresentationPatcher {
* Apply the given algebra operation to the given identifier.
*/
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 {
metadata = new RepresentationMetadata(identifier, INTERNAL_QUADS);
result = new Store<BaseQuad>();
}
const result = representation ? await readableToQuads(representation.data) : new Store();
this.logger.debug(`${result.size} quads in ${identifier.path}.`);
// Run the query through Comunica
const sparql = await readableToString(patch.data);
@ -139,6 +130,7 @@ export class SparqlUpdatePatcher extends RepresentationPatcher {
this.logger.debug(`${result.size} quads will be stored to ${identifier.path}.`);
const metadata = representation?.metadata ?? new RepresentationMetadata(identifier, INTERNAL_QUADS);
return new BasicRepresentation(result.match() as unknown as Readable, metadata, false);
}
}

View File

@ -5,6 +5,7 @@ export const APPLICATION_SPARQL_UPDATE = 'application/sparql-update';
export const APPLICATION_X_WWW_FORM_URLENCODED = 'application/x-www-form-urlencoded';
export const TEXT_HTML = 'text/html';
export const TEXT_MARKDOWN = 'text/markdown';
export const TEXT_N3 = 'text/n3';
export const TEXT_TURTLE = 'text/turtle';
// Internal content types (not exposed over HTTP)

View File

@ -6,6 +6,13 @@ import type { Quad } from 'rdf-js';
import type { Guarded } from './GuardedStream';
import { guardedStreamFrom, pipeSafely } from './StreamUtil';
/**
* Helper function for serializing an array of quads, with as result a Readable object.
* @param quads - The array of quads.
* @param contentType - The content-type to serialize to.
*
* @returns The Readable object.
*/
export function serializeQuads(quads: Quad[], contentType?: string): Guarded<Readable> {
return pipeSafely(guardedStreamFrom(quads), new StreamWriter({ format: contentType }));
}
@ -20,3 +27,18 @@ export function serializeQuads(quads: Quad[], contentType?: string): Guarded<Rea
export async function parseQuads(readable: Guarded<Readable>, options: ParserOptions = {}): Promise<Quad[]> {
return arrayifyStream(pipeSafely(readable, new StreamParser(options)));
}
/**
* Filter out duplicate quads from an array.
* @param quads - Quads to filter.
*
* @returns A new array containing the unique quads.
*/
export function uniqueQuads(quads: Quad[]): Quad[] {
return quads.reduce<Quad[]>((result, quad): Quad[] => {
if (!result.some((item): boolean => quad.equals(item))) {
result.push(quad);
}
return result;
}, []);
}

View File

@ -124,9 +124,14 @@ export const RDF = createUriAndTermNamespace('http://www.w3.org/1999/02/22-rdf-s
);
export const SOLID = createUriAndTermNamespace('http://www.w3.org/ns/solid/terms#',
'deletes',
'inserts',
'oidcIssuer',
'oidcIssuerRegistrationToken',
'oidcRegistration',
'where',
'InsertDeletePatch',
);
export const SOLID_ERROR = createUriAndTermNamespace('urn:npm:solid:community-server:error:',

View File

@ -0,0 +1,15 @@
import type { HttpErrorOptions } from './HttpError';
import { HttpError } from './HttpError';
/**
* An error thrown when the server understands the content-type but can't process the instructions.
*/
export class UnprocessableEntityHttpError extends HttpError {
public constructor(message?: string, options?: HttpErrorOptions) {
super(422, 'UnprocessableEntityHttpError', message, options);
}
public static isInstance(error: any): error is UnprocessableEntityHttpError {
return HttpError.isInstance(error) && error.statusCode === 422;
}
}

View File

@ -1,11 +1,15 @@
import { NotImplementedHttpError } from '../errors/NotImplementedHttpError';
import { AsyncHandler } from './AsyncHandler';
// The formats from which we can detect the method
type InType = { method: string } | { request: { method: string }} | { operation: { method: string }};
/**
* Only accepts requests where the input has a `method` field that matches any one of the given methods.
* Only accepts requests where the input has a (possibly nested) `method` field
* that matches any one of the given methods.
* In case of a match, the input will be sent to the source handler.
*/
export class MethodFilterHandler<TIn extends { method: string }, TOut> extends AsyncHandler<TIn, TOut> {
export class MethodFilterHandler<TIn extends InType, TOut> extends AsyncHandler<TIn, TOut> {
private readonly methods: string[];
private readonly source: AsyncHandler<TIn, TOut>;
@ -16,9 +20,10 @@ export class MethodFilterHandler<TIn extends { method: string }, TOut> extends A
}
public async canHandle(input: TIn): Promise<void> {
if (!this.methods.includes(input.method)) {
const method = this.findMethod(input);
if (!this.methods.includes(method)) {
throw new NotImplementedHttpError(
`Cannot determine permissions of ${input.method}, only ${this.methods.join(',')}.`,
`Cannot determine permissions of ${method}, only ${this.methods.join(',')}.`,
);
}
await this.source.canHandle(input);
@ -27,4 +32,20 @@ export class MethodFilterHandler<TIn extends { method: string }, TOut> extends A
public async handle(input: TIn): Promise<TOut> {
return this.source.handle(input);
}
/**
* Finds the correct method in the input object.
*/
private findMethod(input: InType): string {
if ('method' in input) {
return input.method;
}
if ('request' in input) {
return this.findMethod(input.request);
}
if ('operation' in input) {
return this.findMethod(input.operation);
}
throw new NotImplementedHttpError('Could not find method in input object.');
}
}

View File

@ -11,6 +11,9 @@ module.exports = {
'unicorn/no-useless-undefined': 'off',
'no-process-env': 'off',
// Rule is not smart enough to check called function in the test
'jest/expect-expect': 'off',
// We are not using Mocha
'mocha/no-exports': 'off',
'mocha/no-nested-tests': 'off',

View File

@ -0,0 +1,398 @@
import 'jest-rdf';
import { fetch } from 'cross-fetch';
import { Parser } from 'n3';
import type { AclPermission } from '../../src/authorization/permissions/AclPermission';
import { BasicRepresentation } from '../../src/http/representation/BasicRepresentation';
import type { App } from '../../src/init/App';
import type { ResourceStore } from '../../src/storage/ResourceStore';
import { joinUrl } from '../../src/util/PathUtil';
import { AclHelper } from '../util/AclHelper';
import { getPort } from '../util/Util';
import {
getDefaultVariables,
getPresetConfigPath,
getTestConfigPath,
instantiateFromConfig,
} from './Config';
const port = getPort('N3Patch');
const baseUrl = `http://localhost:${port}/`;
let store: ResourceStore;
let aclHelper: AclHelper;
async function expectPatch(
input: { path: string; contentType?: string; body: string },
expected: { status: number; message?: string; turtle?: string },
): Promise<void> {
const message = expected.message ?? '';
const contentType = input.contentType ?? 'text/n3';
const body = `@prefix solid: <http://www.w3.org/ns/solid/terms#>.
${input.body}`;
const url = joinUrl(baseUrl, input.path);
const res = await fetch(url, {
method: 'PATCH',
headers: { 'content-type': contentType },
body,
});
await expect(res.text()).resolves.toContain(message);
expect(res.status).toBe(expected.status);
// Verify if the resource has the expected RDF data
if (expected.turtle) {
// Might not have read permissions so need to update
await aclHelper.setSimpleAcl(url, { permissions: { read: true }, agentClass: 'agent', accessTo: true });
const get = await fetch(url, {
method: 'GET',
headers: { accept: 'text/turtle' },
});
const expectedTurtle = `@prefix solid: <http://www.w3.org/ns/solid/terms#>.
${expected.turtle}`;
expect(get.status).toBe(200);
const parser = new Parser({ format: 'text/turtle', baseIRI: url });
const actualTriples = parser.parse(await get.text());
expect(actualTriples).toBeRdfIsomorphic(parser.parse(expectedTurtle));
}
}
// Creates/updates a resource with the given data and permissions
async function setResource(path: string, turtle: string, permissions: AclPermission): Promise<void> {
const url = joinUrl(baseUrl, path);
await store.setRepresentation({ path: url }, new BasicRepresentation(turtle, 'text/turtle'));
await aclHelper.setSimpleAcl(url, { permissions, agentClass: 'agent', accessTo: true });
}
describe('A Server supporting N3 Patch', (): void => {
let app: App;
beforeAll(async(): Promise<void> => {
// Create and start the server
const instances = await instantiateFromConfig(
'urn:solid-server:test:Instances',
[
getPresetConfigPath('storage/backend/memory.json'),
getTestConfigPath('ldp-with-auth.json'),
],
getDefaultVariables(port, baseUrl),
) as Record<string, any>;
({ app, store } = instances);
await app.start();
// Create test helper for manipulating acl
aclHelper = new AclHelper(store);
});
afterAll(async(): Promise<void> => {
await app.stop();
});
describe('with an invalid patch document', (): void => {
it('requires text/n3 content-type.', async(): Promise<void> => {
await expectPatch(
{ path: '/invalid', contentType: 'text/other', body: '' },
{ status: 415 },
);
});
it('requires valid syntax.', async(): Promise<void> => {
await expectPatch(
{ path: '/invalid', body: 'invalid syntax' },
{ status: 400, message: 'Invalid N3' },
);
});
it('requires a solid:InsertDeletePatch.', async(): Promise<void> => {
await expectPatch(
{ path: '/invalid', body: '<> a solid:Patch.' },
{
status: 422,
message: 'This patcher only supports N3 Patch documents with exactly 1 solid:InsertDeletePatch entry',
},
);
});
});
describe('inserting data', (): void => {
it('succeeds if there is no resource.', async(): Promise<void> => {
await expectPatch(
{ path: '/new-insert', body: `<> a solid:InsertDeletePatch; solid:inserts { <x> <y> <z>. }.` },
{ status: 201, turtle: '<x> <y> <z>.' },
);
});
it('fails if there is only read access.', async(): Promise<void> => {
await setResource('/read-only', '<a> <b> <c>.', { read: true });
await expectPatch(
{ path: '/read-only', body: `<> a solid:InsertDeletePatch; solid:inserts { <x> <y> <z>. }.` },
{ status: 401 },
);
});
it('succeeds if there is only read access.', async(): Promise<void> => {
await setResource('/append-only', '<a> <b> <c>.', { append: true });
await expectPatch(
{ path: '/append-only', body: `<> a solid:InsertDeletePatch; solid:inserts { <x> <y> <z>. }.` },
{ status: 205, turtle: '<a> <b> <c>. <x> <y> <z>.' },
);
});
it('succeeds if there is only write access.', async(): Promise<void> => {
await setResource('/write-only', '<a> <b> <c>.', { write: true });
await expectPatch(
{ path: '/write-only', body: `<> a solid:InsertDeletePatch; solid:inserts { <x> <y> <z>. }.` },
{ status: 205, turtle: '<a> <b> <c>. <x> <y> <z>.' },
);
});
});
describe('inserting conditional data', (): void => {
it('fails if there is no resource.', async(): Promise<void> => {
await expectPatch(
{ path: '/new-insert-where', body: `<> a solid:InsertDeletePatch;
solid:inserts { ?a <y> <z>. };
solid:where { ?a <b> <c>. }.` },
{ status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' },
);
});
it('fails if there is only read access.', async(): Promise<void> => {
await setResource('/read-only', '<a> <b> <c>.', { read: true });
await expectPatch(
{ path: '/read-only', body: `<> a solid:InsertDeletePatch;
solid:inserts { ?a <y> <z>. };
solid:where { ?a <b> <c>. }.` },
{ status: 401 },
);
});
it('fails if there is only append access.', async(): Promise<void> => {
await setResource('/append-only', '<a> <b> <c>.', { append: true });
await expectPatch(
{ path: '/append-only', body: `<> a solid:InsertDeletePatch;
solid:inserts { ?a <y> <z>. };
solid:where { ?a <b> <c>. }.` },
{ status: 401 },
);
});
it('fails if there is only write access.', async(): Promise<void> => {
await setResource('/write-only', '<a> <b> <c>.', { write: true });
await expectPatch(
{ path: '/write-only', body: `<> a solid:InsertDeletePatch;
solid:inserts { ?a <y> <z>. };
solid:where { ?a <b> <c>. }.` },
{ status: 401 },
);
});
describe('with read/append access', (): void => {
it('succeeds if the conditions match.', async(): Promise<void> => {
await setResource('/read-append', '<a> <b> <c>.', { read: true, append: true });
await expectPatch(
{ path: '/read-append', body: `<> a solid:InsertDeletePatch;
solid:inserts { ?a <y> <z>. };
solid:where { ?a <b> <c>. }.` },
{ status: 205, turtle: '<a> <b> <c>. <a> <y> <z>.' },
);
});
it('rejects if there is no match.', async(): Promise<void> => {
await setResource('/read-append', '<a> <b> <c>.', { read: true, append: true });
await expectPatch(
{ path: '/read-append', body: `<> a solid:InsertDeletePatch;
solid:inserts { ?a <y> <z>. };
solid:where { ?a <y> <z>. }.` },
{ status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' },
);
});
it('rejects if there are multiple matches.', async(): Promise<void> => {
await setResource('/read-append', '<a> <b> <c>. <c> <b> <c>.', { read: true, append: true });
await expectPatch(
{ path: '/read-append', body: `<> a solid:InsertDeletePatch;
solid:inserts { ?a <y> <z>. };
solid:where { ?a <b> <c>. }.` },
{ status: 409, message: 'The document contains multiple matches for the N3 Patch solid:where condition' },
);
});
});
describe('with read/write access', (): void => {
it('succeeds if the conditions match.', async(): Promise<void> => {
await setResource('/read-write', '<a> <b> <c>.', { read: true, write: true });
await expectPatch(
{ path: '/read-write', body: `<> a solid:InsertDeletePatch;
solid:inserts { ?a <y> <z>. };
solid:where { ?a <b> <c>. }.` },
{ status: 205, turtle: '<a> <b> <c>. <a> <y> <z>.' },
);
});
});
});
describe('deleting data', (): void => {
it('fails if there is no resource.', async(): Promise<void> => {
await expectPatch(
{ path: '/new-delete', body: `<> a solid:InsertDeletePatch;
solid:deletes { <x> <y> <z>. }.` },
{ status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' },
);
});
it('fails if there is only append access.', async(): Promise<void> => {
await setResource('/append-only', '<a> <b> <c>.', { append: true });
await expectPatch(
{ path: '/append-only', body: `<> a solid:InsertDeletePatch;
solid:deletes { <x> <y> <z>. }.` },
{ status: 401 },
);
});
it('fails if there is only write access.', async(): Promise<void> => {
await setResource('/write-only', '<a> <b> <c>.', { write: true });
await expectPatch(
{ path: '/write-only', body: `<> a solid:InsertDeletePatch;
solid:deletes { <x> <y> <z>. }.` },
{ status: 401 },
);
});
it('fails if there is only read/append access.', async(): Promise<void> => {
await setResource('/read-append', '<a> <b> <c>.', { read: true, append: true });
await expectPatch(
{ path: '/read-append', body: `<> a solid:InsertDeletePatch;
solid:deletes { <x> <y> <z>. }.` },
{ status: 401 },
);
});
describe('with read/write access', (): void => {
it('succeeds if the delete triples exist.', async(): Promise<void> => {
await setResource('/read-write', '<a> <b> <c>. <x> <y> <z>.', { read: true, write: true });
await expectPatch(
{ path: '/read-write', body: `<> a solid:InsertDeletePatch;
solid:deletes { <x> <y> <z>. }.` },
{ status: 205, turtle: '<a> <b> <c>.' },
);
});
it('fails if the delete triples do not exist.', async(): Promise<void> => {
await setResource('/read-write', '<a> <b> <c>. <x> <y> <z>.', { read: true, write: true });
await expectPatch(
{ path: '/read-write', body: `<> a solid:InsertDeletePatch;
solid:deletes { <a> <y> <z>. }.` },
{ status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' },
);
});
it('succeeds if the conditions match.', async(): Promise<void> => {
await setResource('/read-write', '<a> <b> <c>. <x> <y> <z>.', { read: true, write: true });
await expectPatch(
{ path: '/read-write', body: `<> a solid:InsertDeletePatch;
solid:where { ?a <y> <z>. };
solid:deletes { ?a <y> <z>. }.` },
{ status: 205, turtle: '<a> <b> <c>.' },
);
});
it('fails if the conditions do not match.', async(): Promise<void> => {
await setResource('/read-write', '<a> <b> <c>.', { read: true, write: true });
await expectPatch(
{ path: '/read-write', body: `<> a solid:InsertDeletePatch;
solid:where { ?a <y> <z>. };
solid:deletes { ?a <b> <c>. }.` },
{ status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' },
);
});
});
});
describe('deleting and inserting data', (): void => {
it('fails if there is no resource.', async(): Promise<void> => {
await expectPatch(
{ path: '/new-delete-insert', body: `<> a solid:InsertDeletePatch;
solid:inserts { <x> <y> <z>. };
solid:deletes { <a> <b> <c>. }.` },
{ status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' },
);
});
it('fails if there is only append access.', async(): Promise<void> => {
await setResource('/append-only', '<a> <b> <c>.', { append: true });
await expectPatch(
{ path: '/append-only', body: `<> a solid:InsertDeletePatch;
solid:inserts { <x> <y> <z>. };
solid:deletes { <a> <b> <c>. }.` },
{ status: 401 },
);
});
it('fails if there is only write access.', async(): Promise<void> => {
await setResource('/write-only', '<a> <b> <c>.', { write: true });
await expectPatch(
{ path: '/write-only', body: `<> a solid:InsertDeletePatch;
solid:inserts { <x> <y> <z>. };
solid:deletes { <a> <b> <c>. }.` },
{ status: 401 },
);
});
it('fails if there is only read/append access.', async(): Promise<void> => {
await setResource('/read-append', '<a> <b> <c>.', { read: true, append: true });
await expectPatch(
{ path: '/read-append', body: `<> a solid:InsertDeletePatch;
solid:inserts { <x> <y> <z>. };
solid:deletes { <a> <b> <c>. }.` },
{ status: 401 },
);
});
describe('with read/write access', (): void => {
it('executes deletes before inserts.', async(): Promise<void> => {
await setResource('/read-write', '<a> <b> <c>.', { read: true, write: true });
await expectPatch(
{ path: '/read-write', body: `<> a solid:InsertDeletePatch;
solid:inserts { <x> <y> <z>. };
solid:deletes { <x> <y> <z>. }.` },
{ status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' },
);
});
it('succeeds if the delete triples exist.', async(): Promise<void> => {
await setResource('/read-write', '<a> <b> <c>.', { read: true, write: true });
await expectPatch(
{ path: '/read-write', body: `<> a solid:InsertDeletePatch;
solid:inserts { <x> <y> <z>. };
solid:deletes { <a> <b> <c>. }.` },
{ status: 205, turtle: '<x> <y> <z>.' },
);
});
it('succeeds if the conditions match.', async(): Promise<void> => {
await setResource('/read-write', '<a> <b> <c>.', { read: true, write: true });
await expectPatch(
{ path: '/read-write', body: `<> a solid:InsertDeletePatch;
solid:where { ?a <b> <c>. };
solid:inserts { ?a <y> <z>. };
solid:deletes { ?a <b> <c>. }.` },
{ status: 205, turtle: '<a> <y> <z>.' },
);
});
it('fails if the conditions do not match.', async(): Promise<void> => {
await setResource('/read-write', '<a> <b> <c>.', { read: true, write: true });
await expectPatch(
{ path: '/read-write', body: `<> a solid:InsertDeletePatch;
solid:where { ?a <y> <z>. };
solid:inserts { ?a <y> <z>. };
solid:deletes { ?a <b> <c>. }.` },
{ status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' },
);
});
});
});
});

View File

@ -0,0 +1,62 @@
import { DataFactory } from 'n3';
import type { Quad } from 'rdf-js';
import { N3PatchModesExtractor } from '../../../../src/authorization/permissions/N3PatchModesExtractor';
import { AccessMode } from '../../../../src/authorization/permissions/Permissions';
import type { Operation } from '../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { N3Patch } from '../../../../src/http/representation/N3Patch';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
const { quad, namedNode } = DataFactory;
describe('An N3PatchModesExtractor', (): void => {
const triple: Quad = quad(namedNode('a'), namedNode('b'), namedNode('c'));
let patch: N3Patch;
let operation: Operation;
const extractor = new N3PatchModesExtractor();
beforeEach(async(): Promise<void> => {
patch = new BasicRepresentation() as N3Patch;
patch.deletes = [];
patch.inserts = [];
patch.conditions = [];
operation = {
method: 'PATCH',
body: patch,
preferences: {},
target: { path: 'http://example.com/foo' },
};
});
it('can only handle N3 Patch documents.', async(): Promise<void> => {
operation.body = new BasicRepresentation();
await expect(extractor.canHandle(operation)).rejects.toThrow(NotImplementedHttpError);
operation.body = patch;
await expect(extractor.canHandle(operation)).resolves.toBeUndefined();
});
it('requires read access when there are conditions.', async(): Promise<void> => {
patch.conditions = [ triple ];
await expect(extractor.handle(operation)).resolves.toEqual(new Set([ AccessMode.read ]));
});
it('requires append access when there are inserts.', async(): Promise<void> => {
patch.inserts = [ triple ];
await expect(extractor.handle(operation)).resolves.toEqual(new Set([ AccessMode.append ]));
});
it('requires read and write access when there are inserts.', async(): Promise<void> => {
patch.deletes = [ triple ];
await expect(extractor.handle(operation)).resolves.toEqual(new Set([ AccessMode.read, AccessMode.write ]));
});
it('combines required access modes when required.', async(): Promise<void> => {
patch.conditions = [ triple ];
patch.inserts = [ triple ];
patch.deletes = [ triple ];
await expect(extractor.handle(operation)).resolves
.toEqual(new Set([ AccessMode.read, AccessMode.append, AccessMode.write ]));
});
});

View File

@ -0,0 +1,204 @@
import 'jest-rdf';
import type { Term } from '@rdfjs/types';
import { DataFactory } from 'n3';
import type { BodyParserArgs } from '../../../../../src/http/input/body/BodyParser';
import { N3PatchBodyParser } from '../../../../../src/http/input/body/N3PatchBodyParser';
import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata';
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError';
import { UnsupportedMediaTypeHttpError } from '../../../../../src/util/errors/UnsupportedMediaTypeHttpError';
import { guardedStreamFrom } from '../../../../../src/util/StreamUtil';
const { defaultGraph, literal, namedNode, quad, variable } = DataFactory;
describe('An N3PatchBodyParser', (): void => {
let input: BodyParserArgs;
const parser = new N3PatchBodyParser();
beforeEach(async(): Promise<void> => {
input = {
request: { headers: {}} as HttpRequest,
metadata: new RepresentationMetadata({ path: 'http://example.com/foo' }, 'text/n3'),
};
});
it('can only handle N3 data.', async(): Promise<void> => {
input.metadata.contentType = 'text/plain';
await expect(parser.canHandle(input)).rejects.toThrow(UnsupportedMediaTypeHttpError);
input.metadata.contentType = 'text/n3';
await expect(parser.canHandle(input)).resolves.toBeUndefined();
});
it('errors on invalid N3.', async(): Promise<void> => {
input.request = guardedStreamFrom([ 'invalid syntax' ]) as HttpRequest;
await expect(parser.handle(input)).rejects.toThrow(BadRequestHttpError);
});
it('extracts the patch quads from the request.', async(): Promise<void> => {
const n3 = `@prefix solid: <http://www.w3.org/ns/solid/terms#>.
@prefix ex: <http://www.example.org/terms#>.
_:rename a solid:InsertDeletePatch;
solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". };
solid:inserts { ?person ex:givenName "Alex". };
solid:deletes { ?person ex:givenName "Claudia". }.`;
input.request = guardedStreamFrom([ n3 ]) as HttpRequest;
const patch = await parser.handle(input);
expect(patch.conditions).toBeRdfIsomorphic([
quad(variable('person'), namedNode('http://www.example.org/terms#familyName'), literal('Garcia')),
quad(variable('person'), namedNode('http://www.example.org/terms#nickName'), literal('Garry')),
]);
expect(patch.inserts).toBeRdfIsomorphic([
quad(variable('person'), namedNode('http://www.example.org/terms#givenName'), literal('Alex')),
]);
expect(patch.deletes).toBeRdfIsomorphic([
quad(variable('person'), namedNode('http://www.example.org/terms#givenName'), literal('Claudia')),
]);
});
it('strips the graph from the result quads.', async(): Promise<void> => {
const n3 = `@prefix solid: <http://www.w3.org/ns/solid/terms#>.
@prefix ex: <http://www.example.org/terms#>.
_:rename a solid:InsertDeletePatch;
solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". };
solid:inserts { ?person ex:givenName "Alex". };
solid:deletes { ?person ex:givenName "Claudia". }.`;
input.request = guardedStreamFrom([ n3 ]) as HttpRequest;
const patch = await parser.handle(input);
const quads = [ ...patch.deletes, ...patch.inserts, ...patch.conditions ];
const uniqueGraphs = [ ...new Set(quads.map((entry): Term => entry.graph)) ];
expect(uniqueGraphs).toHaveLength(1);
expect(uniqueGraphs[0]).toEqualRdfTerm(defaultGraph());
});
it('errors if no solid:InsertDeletePatch is found.', async(): Promise<void> => {
const n3 = `@prefix solid: <http://www.w3.org/ns/solid/terms#>.
@prefix ex: <http://www.example.org/terms#>.
_:rename
solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". };
solid:inserts { ?person ex:givenName "Alex". };
solid:deletes { ?person ex:givenName "Claudia". }.`;
input.request = guardedStreamFrom([ n3 ]) as HttpRequest;
await expect(parser.handle(input)).rejects.toThrow(
'This patcher only supports N3 Patch documents with exactly 1 solid:InsertDeletePatch entry, but received 0.',
);
});
it('errors if multiple solid:InsertDeletePatch entries are found.', async(): Promise<void> => {
const n3 = `@prefix solid: <http://www.w3.org/ns/solid/terms#>.
@prefix ex: <http://www.example.org/terms#>.
_:other a solid:InsertDeletePatch.
_:rename a solid:InsertDeletePatch;
solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". };
solid:inserts { ?person ex:givenName "Alex". };
solid:deletes { ?person ex:givenName "Claudia". }.`;
input.request = guardedStreamFrom([ n3 ]) as HttpRequest;
await expect(parser.handle(input)).rejects.toThrow(
'This patcher only supports N3 Patch documents with exactly 1 solid:InsertDeletePatch entry, but received 2.',
);
});
it('errors if the patch subject is not a blank or named node.', async(): Promise<void> => {
const n3 = `@prefix solid: <http://www.w3.org/ns/solid/terms#>.
@prefix ex: <http://www.example.org/terms#>.
?rename a solid:InsertDeletePatch;
solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". };
solid:inserts { ?person ex:givenName "Alex". };
solid:deletes { ?person ex:givenName "Claudia". }.`;
input.request = guardedStreamFrom([ n3 ]) as HttpRequest;
await expect(parser.handle(input)).rejects
.toThrow('An N3 Patch subject needs to be a blank or named node.');
});
it('errors if there are multiple where entries.', async(): Promise<void> => {
const n3 = `@prefix solid: <http://www.w3.org/ns/solid/terms#>.
@prefix ex: <http://www.example.org/terms#>.
_:rename a solid:InsertDeletePatch;
solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". };
solid:where { ?person ex:givenName "Alex". }.`;
input.request = guardedStreamFrom([ n3 ]) as HttpRequest;
await expect(parser.handle(input)).rejects
.toThrow('An N3 Patch can have at most 1 http://www.w3.org/ns/solid/terms#where.');
});
it('errors if there are multiple delete entries.', async(): Promise<void> => {
const n3 = `@prefix solid: <http://www.w3.org/ns/solid/terms#>.
@prefix ex: <http://www.example.org/terms#>.
_:rename a solid:InsertDeletePatch;
solid:deletes { ex:person ex:familyName "Garcia". };
solid:deletes { ex:person ex:givenName "Alex". }.`;
input.request = guardedStreamFrom([ n3 ]) as HttpRequest;
await expect(parser.handle(input)).rejects
.toThrow('An N3 Patch can have at most 1 http://www.w3.org/ns/solid/terms#deletes.');
});
it('errors if there are multiple insert entries.', async(): Promise<void> => {
const n3 = `@prefix solid: <http://www.w3.org/ns/solid/terms#>.
@prefix ex: <http://www.example.org/terms#>.
_:rename a solid:InsertDeletePatch;
solid:inserts { ex:person ex:familyName "Garcia". };
solid:inserts { ex:person ex:givenName "Alex". }.`;
input.request = guardedStreamFrom([ n3 ]) as HttpRequest;
await expect(parser.handle(input)).rejects
.toThrow('An N3 Patch can have at most 1 http://www.w3.org/ns/solid/terms#inserts.');
});
it('errors if there are blank nodes in the delete formula.', async(): Promise<void> => {
const n3 = `@prefix solid: <http://www.w3.org/ns/solid/terms#>.
@prefix ex: <http://www.example.org/terms#>.
_:rename a solid:InsertDeletePatch;
solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". };
solid:inserts { ?person ex:givenName "Alex". };
solid:deletes { _:person ex:givenName "Claudia". }.`;
input.request = guardedStreamFrom([ n3 ]) as HttpRequest;
await expect(parser.handle(input)).rejects
.toThrow('An N3 Patch delete/insert formula can not contain blank nodes.');
});
it('errors if there are blank nodes in the insert formula.', async(): Promise<void> => {
const n3 = `@prefix solid: <http://www.w3.org/ns/solid/terms#>.
@prefix ex: <http://www.example.org/terms#>.
_:rename a solid:InsertDeletePatch;
solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". };
solid:inserts { _:person ex:givenName "Alex". };
solid:deletes { ?person ex:givenName "Claudia". }.`;
input.request = guardedStreamFrom([ n3 ]) as HttpRequest;
await expect(parser.handle(input)).rejects
.toThrow('An N3 Patch delete/insert formula can not contain blank nodes.');
});
it('errors if there are unknown variables in the delete formula.', async(): Promise<void> => {
const n3 = `@prefix solid: <http://www.w3.org/ns/solid/terms#>.
@prefix ex: <http://www.example.org/terms#>.
_:rename a solid:InsertDeletePatch;
solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". };
solid:inserts { ?person ex:givenName "Alex". };
solid:deletes { ?person ex:givenName ?name. }.`;
input.request = guardedStreamFrom([ n3 ]) as HttpRequest;
await expect(parser.handle(input)).rejects
.toThrow('An N3 Patch delete/insert formula can only contain variables found in the conditions formula.');
});
it('errors if there are unknown variables in the insert formula.', async(): Promise<void> => {
const n3 = `@prefix solid: <http://www.w3.org/ns/solid/terms#>.
@prefix ex: <http://www.example.org/terms#>.
_:rename a solid:InsertDeletePatch;
solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". };
solid:inserts { ?person ex:givenName ?name. };
solid:deletes { ?person ex:givenName "Claudia". }.`;
input.request = guardedStreamFrom([ n3 ]) as HttpRequest;
await expect(parser.handle(input)).rejects
.toThrow('An N3 Patch delete/insert formula can only contain variables found in the conditions formula.');
});
});

View File

@ -0,0 +1,155 @@
import 'jest-rdf';
import arrayifyStream from 'arrayify-stream';
import { DataFactory } from 'n3';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { N3Patch } from '../../../../src/http/representation/N3Patch';
import { N3Patcher } from '../../../../src/storage/patch/N3Patcher';
import type { RepresentationPatcherInput } from '../../../../src/storage/patch/RepresentationPatcher';
import { ConflictHttpError } from '../../../../src/util/errors/ConflictHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
const { namedNode, quad, variable } = DataFactory;
describe('An N3Patcher', (): void => {
let patch: N3Patch;
let input: RepresentationPatcherInput;
const patcher = new N3Patcher();
beforeEach(async(): Promise<void> => {
patch = new BasicRepresentation() as N3Patch;
patch.deletes = [];
patch.inserts = [];
patch.conditions = [];
input = {
patch,
identifier: { path: 'http://example.com/foo' },
};
});
it('can only handle N3 Patches.', async(): Promise<void> => {
await expect(patcher.canHandle(input)).resolves.toBeUndefined();
input.patch = new BasicRepresentation() as N3Patch;
await expect(patcher.canHandle(input)).rejects.toThrow(NotImplementedHttpError);
});
it('returns an empty representation for an empty patch for new resources.', async(): Promise<void> => {
patch.deletes = [];
patch.inserts = [];
patch.conditions = [];
const result = await patcher.handle(input);
expect(result.metadata.contentType).toBe('internal/quads');
await expect(arrayifyStream(result.data)).resolves.toEqual([]);
});
it('returns the input representation for an empty patch.', async(): Promise<void> => {
patch.deletes = [];
patch.inserts = [];
patch.conditions = [];
const representation = new BasicRepresentation([], 'internal/quads');
input.representation = representation;
const result = await patcher.handle(input);
expect(result).toBe(representation);
});
it('errors if the input representation has the wrong content-type.', async(): Promise<void> => {
// Just need a non-empty patch
patch.deletes = [ quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')) ];
input.representation = new BasicRepresentation();
await expect(patcher.handle(input)).rejects.toThrow('Quad stream was expected for patching.');
});
it('can delete and insert triples.', async(): Promise<void> => {
patch.deletes = [ quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')) ];
patch.inserts = [ quad(namedNode('ex:s2'), namedNode('ex:p2'), namedNode('ex:o2')) ];
input.representation = new BasicRepresentation([
quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')),
quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')),
], 'internal/quads', false);
const result = await patcher.handle(input);
expect(result.metadata.contentType).toBe('internal/quads');
await expect(arrayifyStream(result.data)).resolves.toBeRdfIsomorphic([
quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')),
quad(namedNode('ex:s2'), namedNode('ex:p2'), namedNode('ex:o2')),
]);
});
it('can create new representations using insert.', async(): Promise<void> => {
patch.inserts = [ quad(namedNode('ex:s2'), namedNode('ex:p2'), namedNode('ex:o2')) ];
const result = await patcher.handle(input);
expect(result.metadata.contentType).toBe('internal/quads');
await expect(arrayifyStream(result.data)).resolves.toBeRdfIsomorphic([
quad(namedNode('ex:s2'), namedNode('ex:p2'), namedNode('ex:o2')),
]);
});
it('can use conditions to target specific triples.', async(): Promise<void> => {
patch.conditions = [ quad(variable('v'), namedNode('ex:p1'), namedNode('ex:o1')) ];
patch.deletes = [ quad(variable('v'), namedNode('ex:p1'), namedNode('ex:o1')) ];
patch.inserts = [ quad(variable('v'), namedNode('ex:p2'), namedNode('ex:o2')) ];
input.representation = new BasicRepresentation([
quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')),
quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')),
], 'internal/quads', false);
const result = await patcher.handle(input);
expect(result.metadata.contentType).toBe('internal/quads');
await expect(arrayifyStream(result.data)).resolves.toBeRdfIsomorphic([
quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')),
quad(namedNode('ex:s1'), namedNode('ex:p2'), namedNode('ex:o2')),
]);
});
it('errors if the conditions find no match.', async(): Promise<void> => {
patch.conditions = [ quad(variable('v'), namedNode('ex:p3'), namedNode('ex:o3')) ];
input.representation = new BasicRepresentation([
quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')),
quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')),
], 'internal/quads', false);
const prom = patcher.handle(input);
await expect(prom).rejects.toThrow(ConflictHttpError);
await expect(prom).rejects.toThrow(
'The document does not contain any matches for the N3 Patch solid:where condition.',
);
});
it('errors if the conditions find multiple matches.', async(): Promise<void> => {
patch.conditions = [ quad(variable('v'), namedNode('ex:p0'), namedNode('ex:o0')) ];
input.representation = new BasicRepresentation([
quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')),
quad(namedNode('ex:s1'), namedNode('ex:p0'), namedNode('ex:o0')),
], 'internal/quads', false);
const prom = patcher.handle(input);
await expect(prom).rejects.toThrow(ConflictHttpError);
await expect(prom).rejects.toThrow(
'The document contains multiple matches for the N3 Patch solid:where condition, which is not allowed.',
);
});
it('errors if the delete triples have no match.', async(): Promise<void> => {
patch.deletes = [ quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')) ];
input.representation = new BasicRepresentation([
quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')),
], 'internal/quads', false);
const prom = patcher.handle(input);
await expect(prom).rejects.toThrow(ConflictHttpError);
await expect(prom).rejects.toThrow(
'The document does not contain all triples the N3 Patch requests to delete, which is required for patching.',
);
});
it('works correctly if there are duplicate delete triples.', async(): Promise<void> => {
patch.conditions = [ quad(variable('v'), namedNode('ex:p1'), namedNode('ex:o1')) ];
patch.deletes = [
quad(variable('v'), namedNode('ex:p1'), namedNode('ex:o1')),
quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')),
];
input.representation = new BasicRepresentation([
quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')),
quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')),
], 'internal/quads', false);
const result = await patcher.handle(input);
expect(result.metadata.contentType).toBe('internal/quads');
await expect(arrayifyStream(result.data)).resolves.toBeRdfIsomorphic([
quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')),
]);
});
});

View File

@ -1,6 +1,6 @@
import 'jest-rdf';
import { literal, namedNode, quad } from '@rdfjs/data-model';
import { parseQuads, serializeQuads } from '../../../src/util/QuadUtil';
import { parseQuads, serializeQuads, uniqueQuads } from '../../../src/util/QuadUtil';
import { guardedStreamFrom, readableToString } from '../../../src/util/StreamUtil';
describe('QuadUtil', (): void => {
@ -35,4 +35,18 @@ describe('QuadUtil', (): void => {
) ]);
});
});
describe('#uniqueQuads', (): void => {
it('filters out duplicate quads.', async(): Promise<void> => {
const quads = [
quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')),
quad(namedNode('ex:s2'), namedNode('ex:p2'), namedNode('ex:o2')),
quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')),
];
expect(uniqueQuads(quads)).toBeRdfIsomorphic([
quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')),
quad(namedNode('ex:s2'), namedNode('ex:p2'), namedNode('ex:o2')),
]);
});
});
});

View File

@ -10,6 +10,7 @@ import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplemen
import { PayloadHttpError } from '../../../../src/util/errors/PayloadHttpError';
import { PreconditionFailedHttpError } from '../../../../src/util/errors/PreconditionFailedHttpError';
import { UnauthorizedHttpError } from '../../../../src/util/errors/UnauthorizedHttpError';
import { UnprocessableEntityHttpError } from '../../../../src/util/errors/UnprocessableEntityHttpError';
import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError';
// Only used to make typings easier in the tests
@ -30,6 +31,7 @@ describe('HttpError', (): void => {
[ 'PreconditionFailedHttpError', 412, PreconditionFailedHttpError ],
[ 'PayloadHttpError', 413, PayloadHttpError ],
[ 'UnsupportedMediaTypeHttpError', 415, UnsupportedMediaTypeHttpError ],
[ 'UnprocessableEntityHttpError', 422, UnprocessableEntityHttpError ],
[ 'InternalServerError', 500, InternalServerError ],
[ 'NotImplementedHttpError', 501, NotImplementedHttpError ],
];

View File

@ -11,7 +11,7 @@ describe('A MethodFilterHandler', (): void => {
const result = 'RESULT';
let operation: Operation;
let source: jest.Mocked<AsyncHandler<Operation, string>>;
let handler: MethodFilterHandler<Operation, string>;
let handler: MethodFilterHandler<any, string>;
beforeEach(async(): Promise<void> => {
operation = {
@ -45,6 +45,17 @@ describe('A MethodFilterHandler', (): void => {
expect(source.canHandle).toHaveBeenLastCalledWith(operation);
});
it('supports multiple object formats.', async(): Promise<void> => {
let input: any = { method: 'PATCH' };
await expect(handler.canHandle(input)).resolves.toBeUndefined();
input = { operation: { method: 'PATCH' }};
await expect(handler.canHandle(input)).resolves.toBeUndefined();
input = { request: { method: 'PATCH' }};
await expect(handler.canHandle(input)).resolves.toBeUndefined();
input = { unknown: { method: 'PATCH' }};
await expect(handler.canHandle(input)).rejects.toThrow('Could not find method in input object.');
});
it('calls the source extractor.', async(): Promise<void> => {
await expect(handler.handle(operation)).resolves.toBe(result);
expect(source.handle).toHaveBeenLastCalledWith(operation);

View File

@ -17,7 +17,7 @@ export async function getResource(url: string,
expect(response.status).toBe(200);
expect(response.headers.get('link')).toContain(`<${LDP.Resource}>; rel="type"`);
expect(response.headers.get('link')).toContain(`<${url}.acl>; rel="acl"`);
expect(response.headers.get('accept-patch')).toBe('application/sparql-update');
expect(response.headers.get('accept-patch')).toBe('application/sparql-update, text/n3');
expect(response.headers.get('ms-author-via')).toBe('SPARQL');
if (isContainer) {

View File

@ -11,6 +11,7 @@ const portNames = [
'LpdHandlerWithAuth',
'LpdHandlerWithoutAuth',
'Middleware',
'N3Patch',
'PodCreation',
'RedisResourceLocker',
'RestrictedIdentity',