diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ada7c85f9..7e91caf8c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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. diff --git a/config/ldp/handler/components/request-parser.json b/config/ldp/handler/components/request-parser.json index da90f2480..af950558e 100644 --- a/config/ldp/handler/components/request-parser.json +++ b/config/ldp/handler/components/request-parser.json @@ -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" } + ] + } } ] } diff --git a/config/ldp/metadata-writer/writers/constant.json b/config/ldp/metadata-writer/writers/constant.json index aaaaf8ef0..ea4455d4b 100644 --- a/config/ldp/metadata-writer/writers/constant.json +++ b/config/ldp/metadata-writer/writers/constant.json @@ -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", diff --git a/config/ldp/modes/default.json b/config/ldp/modes/default.json index 1fa1e27ef..403f25bed 100644 --- a/config/ldp/modes/default.json +++ b/config/ldp/modes/default.json @@ -27,6 +27,7 @@ "source": { "@type": "WaterfallHandler", "handlers": [ + { "@type": "N3PatchModesExtractor" }, { "@type": "SparqlUpdateModesExtractor" }, { "@type": "StaticThrowHandler", diff --git a/config/storage/middleware/stores/patching.json b/config/storage/middleware/stores/patching.json index 033be5332..480253f91 100644 --- a/config/storage/middleware/stores/patching.json +++ b/config/storage/middleware/stores/patching.json @@ -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" } + ] } ] } diff --git a/package-lock.json b/package-lock.json index 14c2c49a9..868c4afd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 4580f267b..e43d29c5c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/authorization/permissions/N3PatchModesExtractor.ts b/src/authorization/permissions/N3PatchModesExtractor.ts new file mode 100644 index 000000000..bc4d8a2cc --- /dev/null +++ b/src/authorization/permissions/N3PatchModesExtractor.ts @@ -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 { + if (!isN3Patch(body)) { + throw new NotImplementedHttpError('Can only determine permissions of N3 Patch documents.'); + } + } + + public async handle({ body }: Operation): Promise> { + const { deletes, inserts, conditions } = body as N3Patch; + + const accessModes = new Set(); + + // 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; + } +} diff --git a/src/http/input/body/N3PatchBodyParser.ts b/src/http/input/body/N3PatchBodyParser.ts new file mode 100644 index 000000000..d6a1ccfd0 --- /dev/null +++ b/src/http/input/body/N3PatchBodyParser.ts @@ -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 { + if (metadata.contentType !== TEXT_N3) { + throw new UnsupportedMediaTypeHttpError('This parser only supports N3 Patch documents.'); + } + } + + public async handle({ request, metadata }: BodyParserArgs): Promise { + 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 { + 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): 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.`, + ); + } + } + } + } +} diff --git a/src/http/representation/N3Patch.ts b/src/http/representation/N3Patch.ts new file mode 100644 index 000000000..80ab1c6e8 --- /dev/null +++ b/src/http/representation/N3Patch.ts @@ -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); +} diff --git a/src/index.ts b/src/index.ts index cd70639ea..498bd2212 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/storage/patch/N3Patcher.ts b/src/storage/patch/N3Patcher.ts new file mode 100644 index 000000000..ac4f0a9df --- /dev/null +++ b/src/storage/patch/N3Patcher.ts @@ -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 { + if (!isN3Patch(patch)) { + throw new NotImplementedHttpError('Only N3 Patch updates are supported'); + } + } + + public async handle(input: RepresentationPatcherInput): Promise { + 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 { + 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 { + 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, (term): Term => + term.termType === 'Variable' ? bindings[0].get(`?${term.value}`) : term)); + inserts = inserts.map((quad): Quad => mapTerms(quad, (term): Term => + term.termType === 'Variable' ? bindings[0].get(`?${term.value}`) : term)); + } + + return { + ...patch, + deletes, + inserts, + conditions: [], + }; + } +} diff --git a/src/storage/patch/SparqlUpdatePatcher.ts b/src/storage/patch/SparqlUpdatePatcher.ts index 92d707b28..735b39eb7 100644 --- a/src/storage/patch/SparqlUpdatePatcher.ts +++ b/src/storage/patch/SparqlUpdatePatcher.ts @@ -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 { - let result: Store; - 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(); - } + 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); } } diff --git a/src/util/ContentTypes.ts b/src/util/ContentTypes.ts index e45cccca8..47481171d 100644 --- a/src/util/ContentTypes.ts +++ b/src/util/ContentTypes.ts @@ -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) diff --git a/src/util/QuadUtil.ts b/src/util/QuadUtil.ts index eb9b81f9d..9f50029a7 100644 --- a/src/util/QuadUtil.ts +++ b/src/util/QuadUtil.ts @@ -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 { return pipeSafely(guardedStreamFrom(quads), new StreamWriter({ format: contentType })); } @@ -20,3 +27,18 @@ export function serializeQuads(quads: Quad[], contentType?: string): Guarded, options: ParserOptions = {}): Promise { 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((result, quad): Quad[] => { + if (!result.some((item): boolean => quad.equals(item))) { + result.push(quad); + } + return result; + }, []); +} diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index 32384d9b7..7742949f8 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -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:', diff --git a/src/util/errors/UnprocessableEntityHttpError.ts b/src/util/errors/UnprocessableEntityHttpError.ts new file mode 100644 index 000000000..1ff8c039a --- /dev/null +++ b/src/util/errors/UnprocessableEntityHttpError.ts @@ -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; + } +} diff --git a/src/util/handlers/MethodFilterHandler.ts b/src/util/handlers/MethodFilterHandler.ts index a358547e2..e0c5814c6 100644 --- a/src/util/handlers/MethodFilterHandler.ts +++ b/src/util/handlers/MethodFilterHandler.ts @@ -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 extends AsyncHandler { +export class MethodFilterHandler extends AsyncHandler { private readonly methods: string[]; private readonly source: AsyncHandler; @@ -16,9 +20,10 @@ export class MethodFilterHandler extends A } public async canHandle(input: TIn): Promise { - 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 extends A public async handle(input: TIn): Promise { 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.'); + } } diff --git a/test/.eslintrc.js b/test/.eslintrc.js index 585742262..58ba353d6 100644 --- a/test/.eslintrc.js +++ b/test/.eslintrc.js @@ -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', diff --git a/test/integration/N3Patch.test.ts b/test/integration/N3Patch.test.ts new file mode 100644 index 000000000..21281c91a --- /dev/null +++ b/test/integration/N3Patch.test.ts @@ -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 { + const message = expected.message ?? ''; + const contentType = input.contentType ?? 'text/n3'; + + const body = `@prefix solid: . + ${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: . + ${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 { + 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 => { + // 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; + ({ app, store } = instances); + + await app.start(); + + // Create test helper for manipulating acl + aclHelper = new AclHelper(store); + }); + + afterAll(async(): Promise => { + await app.stop(); + }); + + describe('with an invalid patch document', (): void => { + it('requires text/n3 content-type.', async(): Promise => { + await expectPatch( + { path: '/invalid', contentType: 'text/other', body: '' }, + { status: 415 }, + ); + }); + + it('requires valid syntax.', async(): Promise => { + await expectPatch( + { path: '/invalid', body: 'invalid syntax' }, + { status: 400, message: 'Invalid N3' }, + ); + }); + + it('requires a solid:InsertDeletePatch.', async(): Promise => { + 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 => { + await expectPatch( + { path: '/new-insert', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, + { status: 201, turtle: ' .' }, + ); + }); + + it('fails if there is only read access.', async(): Promise => { + await setResource('/read-only', ' .', { read: true }); + await expectPatch( + { path: '/read-only', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, + { status: 401 }, + ); + }); + + it('succeeds if there is only read access.', async(): Promise => { + await setResource('/append-only', ' .', { append: true }); + await expectPatch( + { path: '/append-only', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, + { status: 205, turtle: ' . .' }, + ); + }); + + it('succeeds if there is only write access.', async(): Promise => { + await setResource('/write-only', ' .', { write: true }); + await expectPatch( + { path: '/write-only', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, + { status: 205, turtle: ' . .' }, + ); + }); + }); + + describe('inserting conditional data', (): void => { + it('fails if there is no resource.', async(): Promise => { + await expectPatch( + { path: '/new-insert-where', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { 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 => { + await setResource('/read-only', ' .', { read: true }); + await expectPatch( + { path: '/read-only', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 401 }, + ); + }); + + it('fails if there is only append access.', async(): Promise => { + await setResource('/append-only', ' .', { append: true }); + await expectPatch( + { path: '/append-only', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 401 }, + ); + }); + + it('fails if there is only write access.', async(): Promise => { + await setResource('/write-only', ' .', { write: true }); + await expectPatch( + { path: '/write-only', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 401 }, + ); + }); + + describe('with read/append access', (): void => { + it('succeeds if the conditions match.', async(): Promise => { + await setResource('/read-append', ' .', { read: true, append: true }); + await expectPatch( + { path: '/read-append', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 205, turtle: ' . .' }, + ); + }); + + it('rejects if there is no match.', async(): Promise => { + await setResource('/read-append', ' .', { read: true, append: true }); + await expectPatch( + { path: '/read-append', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { 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 => { + await setResource('/read-append', ' . .', { read: true, append: true }); + await expectPatch( + { path: '/read-append', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { 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 => { + await setResource('/read-write', ' .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 205, turtle: ' . .' }, + ); + }); + }); + }); + + describe('deleting data', (): void => { + it('fails if there is no resource.', async(): Promise => { + await expectPatch( + { path: '/new-delete', body: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` }, + { 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 => { + await setResource('/append-only', ' .', { append: true }); + await expectPatch( + { path: '/append-only', body: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` }, + { status: 401 }, + ); + }); + + it('fails if there is only write access.', async(): Promise => { + await setResource('/write-only', ' .', { write: true }); + await expectPatch( + { path: '/write-only', body: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` }, + { status: 401 }, + ); + }); + + it('fails if there is only read/append access.', async(): Promise => { + await setResource('/read-append', ' .', { read: true, append: true }); + await expectPatch( + { path: '/read-append', body: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` }, + { status: 401 }, + ); + }); + + describe('with read/write access', (): void => { + it('succeeds if the delete triples exist.', async(): Promise => { + await setResource('/read-write', ' . .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` }, + { status: 205, turtle: ' .' }, + ); + }); + + it('fails if the delete triples do not exist.', async(): Promise => { + await setResource('/read-write', ' . .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` }, + { status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' }, + ); + }); + + it('succeeds if the conditions match.', async(): Promise => { + await setResource('/read-write', ' . .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:where { ?a . }; + solid:deletes { ?a . }.` }, + { status: 205, turtle: ' .' }, + ); + }); + + it('fails if the conditions do not match.', async(): Promise => { + await setResource('/read-write', ' .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:where { ?a . }; + solid:deletes { ?a . }.` }, + { 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 => { + await expectPatch( + { path: '/new-delete-insert', body: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` }, + { 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 => { + await setResource('/append-only', ' .', { append: true }); + await expectPatch( + { path: '/append-only', body: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` }, + { status: 401 }, + ); + }); + + it('fails if there is only write access.', async(): Promise => { + await setResource('/write-only', ' .', { write: true }); + await expectPatch( + { path: '/write-only', body: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` }, + { status: 401 }, + ); + }); + + it('fails if there is only read/append access.', async(): Promise => { + await setResource('/read-append', ' .', { read: true, append: true }); + await expectPatch( + { path: '/read-append', body: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` }, + { status: 401 }, + ); + }); + + describe('with read/write access', (): void => { + it('executes deletes before inserts.', async(): Promise => { + await setResource('/read-write', ' .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` }, + { 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 => { + await setResource('/read-write', ' .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` }, + { status: 205, turtle: ' .' }, + ); + }); + + it('succeeds if the conditions match.', async(): Promise => { + await setResource('/read-write', ' .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:where { ?a . }; + solid:inserts { ?a . }; + solid:deletes { ?a . }.` }, + { status: 205, turtle: ' .' }, + ); + }); + + it('fails if the conditions do not match.', async(): Promise => { + await setResource('/read-write', ' .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:where { ?a . }; + solid:inserts { ?a . }; + solid:deletes { ?a . }.` }, + { status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' }, + ); + }); + }); + }); +}); diff --git a/test/unit/authorization/permissions/N3PatchModesExtractor.test.ts b/test/unit/authorization/permissions/N3PatchModesExtractor.test.ts new file mode 100644 index 000000000..7b4688858 --- /dev/null +++ b/test/unit/authorization/permissions/N3PatchModesExtractor.test.ts @@ -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 => { + 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 => { + 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 => { + patch.conditions = [ triple ]; + await expect(extractor.handle(operation)).resolves.toEqual(new Set([ AccessMode.read ])); + }); + + it('requires append access when there are inserts.', async(): Promise => { + 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 => { + 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 => { + patch.conditions = [ triple ]; + patch.inserts = [ triple ]; + patch.deletes = [ triple ]; + await expect(extractor.handle(operation)).resolves + .toEqual(new Set([ AccessMode.read, AccessMode.append, AccessMode.write ])); + }); +}); diff --git a/test/unit/http/input/body/N3PatchBodyParser.test.ts b/test/unit/http/input/body/N3PatchBodyParser.test.ts new file mode 100644 index 000000000..902baec41 --- /dev/null +++ b/test/unit/http/input/body/N3PatchBodyParser.test.ts @@ -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 => { + input = { + request: { headers: {}} as HttpRequest, + metadata: new RepresentationMetadata({ path: 'http://example.com/foo' }, 'text/n3'), + }; + }); + + it('can only handle N3 data.', async(): Promise => { + 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 => { + 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 => { + const n3 = `@prefix solid: . +@prefix ex: . + +_: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 => { + const n3 = `@prefix solid: . +@prefix ex: . + +_: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 => { + const n3 = `@prefix solid: . +@prefix ex: . + +_: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 => { + const n3 = `@prefix solid: . +@prefix ex: . + +_: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 => { + const n3 = `@prefix solid: . +@prefix ex: . + +?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 => { + const n3 = `@prefix solid: . +@prefix ex: . + +_: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 => { + const n3 = `@prefix solid: . +@prefix ex: . + +_: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 => { + const n3 = `@prefix solid: . +@prefix ex: . + +_: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 => { + const n3 = `@prefix solid: . +@prefix ex: . + +_: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 => { + const n3 = `@prefix solid: . +@prefix ex: . + +_: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 => { + const n3 = `@prefix solid: . +@prefix ex: . + +_: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 => { + const n3 = `@prefix solid: . +@prefix ex: . + +_: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.'); + }); +}); diff --git a/test/unit/storage/patch/N3Patcher.test.ts b/test/unit/storage/patch/N3Patcher.test.ts new file mode 100644 index 000000000..f0a14c517 --- /dev/null +++ b/test/unit/storage/patch/N3Patcher.test.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + // 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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')), + ]); + }); +}); diff --git a/test/unit/util/QuadUtil.test.ts b/test/unit/util/QuadUtil.test.ts index 236b65c34..c7ffc9d8a 100644 --- a/test/unit/util/QuadUtil.test.ts +++ b/test/unit/util/QuadUtil.test.ts @@ -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 => { + 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')), + ]); + }); + }); }); diff --git a/test/unit/util/errors/HttpError.test.ts b/test/unit/util/errors/HttpError.test.ts index c62e6d3ef..7847bfea4 100644 --- a/test/unit/util/errors/HttpError.test.ts +++ b/test/unit/util/errors/HttpError.test.ts @@ -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 ], ]; diff --git a/test/unit/util/handlers/MethodFilterHandler.test.ts b/test/unit/util/handlers/MethodFilterHandler.test.ts index 0b1ef1a49..8f688144c 100644 --- a/test/unit/util/handlers/MethodFilterHandler.test.ts +++ b/test/unit/util/handlers/MethodFilterHandler.test.ts @@ -11,7 +11,7 @@ describe('A MethodFilterHandler', (): void => { const result = 'RESULT'; let operation: Operation; let source: jest.Mocked>; - let handler: MethodFilterHandler; + let handler: MethodFilterHandler; beforeEach(async(): Promise => { operation = { @@ -45,6 +45,17 @@ describe('A MethodFilterHandler', (): void => { expect(source.canHandle).toHaveBeenLastCalledWith(operation); }); + it('supports multiple object formats.', async(): Promise => { + 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 => { await expect(handler.handle(operation)).resolves.toBe(result); expect(source.handle).toHaveBeenLastCalledWith(operation); diff --git a/test/util/FetchUtil.ts b/test/util/FetchUtil.ts index 31715c187..358470401 100644 --- a/test/util/FetchUtil.ts +++ b/test/util/FetchUtil.ts @@ -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) { diff --git a/test/util/Util.ts b/test/util/Util.ts index 9b6ac63f8..25c132445 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -11,6 +11,7 @@ const portNames = [ 'LpdHandlerWithAuth', 'LpdHandlerWithoutAuth', 'Middleware', + 'N3Patch', 'PodCreation', 'RedisResourceLocker', 'RestrictedIdentity',