mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Add support for N3 Patch
This commit is contained in:
parent
1afed65368
commit
a9941ebe78
@ -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`
|
- 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.
|
- 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.
|
- 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
|
### Configuration changes
|
||||||
You might need to make changes to your v2 configuration if you use a custom config.
|
You might need to make changes to your v2 configuration if you use a custom config.
|
||||||
|
@ -16,10 +16,23 @@
|
|||||||
"args_bodyParser": {
|
"args_bodyParser": {
|
||||||
"@type": "WaterfallHandler",
|
"@type": "WaterfallHandler",
|
||||||
"handlers": [
|
"handlers": [
|
||||||
{ "@type": "SparqlUpdateBodyParser" },
|
{ "@id": "urn:solid-server:default:PatchBodyParser" },
|
||||||
{ "@type": "RawBodyParser" }
|
{ "@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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
"headers": [
|
"headers": [
|
||||||
{
|
{
|
||||||
"ConstantMetadataWriter:_headers_key": "Accept-Patch",
|
"ConstantMetadataWriter:_headers_key": "Accept-Patch",
|
||||||
"ConstantMetadataWriter:_headers_value": "application/sparql-update"
|
"ConstantMetadataWriter:_headers_value": "application/sparql-update, text/n3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ConstantMetadataWriter:_headers_key": "Allow",
|
"ConstantMetadataWriter:_headers_key": "Allow",
|
||||||
|
@ -27,6 +27,7 @@
|
|||||||
"source": {
|
"source": {
|
||||||
"@type": "WaterfallHandler",
|
"@type": "WaterfallHandler",
|
||||||
"handlers": [
|
"handlers": [
|
||||||
|
{ "@type": "N3PatchModesExtractor" },
|
||||||
{ "@type": "SparqlUpdateModesExtractor" },
|
{ "@type": "SparqlUpdateModesExtractor" },
|
||||||
{
|
{
|
||||||
"@type": "StaticThrowHandler",
|
"@type": "StaticThrowHandler",
|
||||||
|
@ -14,11 +14,11 @@
|
|||||||
{
|
{
|
||||||
"comment": "Makes sure PATCH operations on containers target the metadata.",
|
"comment": "Makes sure PATCH operations on containers target the metadata.",
|
||||||
"@type": "ContainerPatcher",
|
"@type": "ContainerPatcher",
|
||||||
"patcher": { "@type": "SparqlUpdatePatcher" }
|
"patcher": { "@id": "urn:solid-server:default:PatchHandler_RDF" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@type": "ConvertingPatcher",
|
"@type": "ConvertingPatcher",
|
||||||
"patcher": { "@type": "SparqlUpdatePatcher" },
|
"patcher": { "@id": "urn:solid-server:default:PatchHandler_RDF" },
|
||||||
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
|
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
|
||||||
"intermediateType": "internal/quads",
|
"intermediateType": "internal/quads",
|
||||||
"defaultType": "text/turtle"
|
"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
31
package-lock.json
generated
@ -54,6 +54,7 @@
|
|||||||
"punycode": "^2.1.1",
|
"punycode": "^2.1.1",
|
||||||
"rdf-parse": "^1.8.1",
|
"rdf-parse": "^1.8.1",
|
||||||
"rdf-serialize": "^1.1.0",
|
"rdf-serialize": "^1.1.0",
|
||||||
|
"rdf-terms": "^1.7.1",
|
||||||
"redis": "^3.1.2",
|
"redis": "^3.1.2",
|
||||||
"redlock": "^4.2.0",
|
"redlock": "^4.2.0",
|
||||||
"sparqlalgebrajs": "^4.0.1",
|
"sparqlalgebrajs": "^4.0.1",
|
||||||
@ -11360,11 +11361,6 @@
|
|||||||
"integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=",
|
"integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=",
|
||||||
"dev": true
|
"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": {
|
"node_modules/logform": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/logform/-/logform-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/logform/-/logform-2.2.0.tgz",
|
||||||
@ -13542,13 +13538,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rdf-terms": {
|
"node_modules/rdf-terms": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.7.1.tgz",
|
||||||
"integrity": "sha512-K83ACD+MuWFS3mNxwCRNYQAmc/Z9iK7PgqJq9N4VP8sUVlP7ioB2pPNQHKHy0IQh4RTkEq6fg4R4q7YlweLBZQ==",
|
"integrity": "sha512-zhYKqTrXTsoybs05Dpu1b+FDnS3+RsU4Fxsqj5aG7frPXDx0MMnIQOKUKpJL7KKYOtq/JE5JsLup6lggnxPqig==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rdfjs/types": "*",
|
"@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": {
|
"node_modules/rdfa-streaming-parser": {
|
||||||
@ -24527,11 +24523,6 @@
|
|||||||
"integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=",
|
"integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=",
|
||||||
"dev": true
|
"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": {
|
"logform": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/logform/-/logform-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/logform/-/logform-2.2.0.tgz",
|
||||||
@ -26206,13 +26197,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rdf-terms": {
|
"rdf-terms": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.7.1.tgz",
|
||||||
"integrity": "sha512-K83ACD+MuWFS3mNxwCRNYQAmc/Z9iK7PgqJq9N4VP8sUVlP7ioB2pPNQHKHy0IQh4RTkEq6fg4R4q7YlweLBZQ==",
|
"integrity": "sha512-zhYKqTrXTsoybs05Dpu1b+FDnS3+RsU4Fxsqj5aG7frPXDx0MMnIQOKUKpJL7KKYOtq/JE5JsLup6lggnxPqig==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@rdfjs/types": "*",
|
"@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": {
|
"rdfa-streaming-parser": {
|
||||||
|
@ -120,6 +120,7 @@
|
|||||||
"punycode": "^2.1.1",
|
"punycode": "^2.1.1",
|
||||||
"rdf-parse": "^1.8.1",
|
"rdf-parse": "^1.8.1",
|
||||||
"rdf-serialize": "^1.1.0",
|
"rdf-serialize": "^1.1.0",
|
||||||
|
"rdf-terms": "^1.7.1",
|
||||||
"redis": "^3.1.2",
|
"redis": "^3.1.2",
|
||||||
"redlock": "^4.2.0",
|
"redlock": "^4.2.0",
|
||||||
"sparqlalgebrajs": "^4.0.1",
|
"sparqlalgebrajs": "^4.0.1",
|
||||||
|
44
src/authorization/permissions/N3PatchModesExtractor.ts
Normal file
44
src/authorization/permissions/N3PatchModesExtractor.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
138
src/http/input/body/N3PatchBodyParser.ts
Normal file
138
src/http/input/body/N3PatchBodyParser.ts
Normal 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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
18
src/http/representation/N3Patch.ts
Normal file
18
src/http/representation/N3Patch.ts
Normal 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);
|
||||||
|
}
|
@ -18,6 +18,7 @@ export * from './authorization/access/AgentGroupAccessChecker';
|
|||||||
export * from './authorization/permissions/Permissions';
|
export * from './authorization/permissions/Permissions';
|
||||||
export * from './authorization/permissions/ModesExtractor';
|
export * from './authorization/permissions/ModesExtractor';
|
||||||
export * from './authorization/permissions/MethodModesExtractor';
|
export * from './authorization/permissions/MethodModesExtractor';
|
||||||
|
export * from './authorization/permissions/N3PatchModesExtractor';
|
||||||
export * from './authorization/permissions/SparqlUpdateModesExtractor';
|
export * from './authorization/permissions/SparqlUpdateModesExtractor';
|
||||||
|
|
||||||
// Authorization
|
// Authorization
|
||||||
@ -45,6 +46,7 @@ export * from './http/auxiliary/Validator';
|
|||||||
|
|
||||||
// HTTP/Input/Body
|
// HTTP/Input/Body
|
||||||
export * from './http/input/body/BodyParser';
|
export * from './http/input/body/BodyParser';
|
||||||
|
export * from './http/input/body/N3PatchBodyParser';
|
||||||
export * from './http/input/body/RawBodyParser';
|
export * from './http/input/body/RawBodyParser';
|
||||||
export * from './http/input/body/SparqlUpdateBodyParser';
|
export * from './http/input/body/SparqlUpdateBodyParser';
|
||||||
|
|
||||||
@ -295,6 +297,7 @@ export * from './storage/mapping/SubdomainExtensionBasedMapper';
|
|||||||
// Storage/Patch
|
// Storage/Patch
|
||||||
export * from './storage/patch/ContainerPatcher';
|
export * from './storage/patch/ContainerPatcher';
|
||||||
export * from './storage/patch/ConvertingPatcher';
|
export * from './storage/patch/ConvertingPatcher';
|
||||||
|
export * from './storage/patch/N3Patcher';
|
||||||
export * from './storage/patch/PatchHandler';
|
export * from './storage/patch/PatchHandler';
|
||||||
export * from './storage/patch/RepresentationPatcher';
|
export * from './storage/patch/RepresentationPatcher';
|
||||||
export * from './storage/patch/RepresentationPatchHandler';
|
export * from './storage/patch/RepresentationPatchHandler';
|
||||||
|
160
src/storage/patch/N3Patcher.ts
Normal file
160
src/storage/patch/N3Patcher.ts
Normal 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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -4,7 +4,6 @@ import { newEngine } from '@comunica/actor-init-sparql';
|
|||||||
import type { IQueryResultUpdate } from '@comunica/actor-init-sparql/lib/ActorInitSparql-browser';
|
import type { IQueryResultUpdate } from '@comunica/actor-init-sparql/lib/ActorInitSparql-browser';
|
||||||
import { defaultGraph } from '@rdfjs/data-model';
|
import { defaultGraph } from '@rdfjs/data-model';
|
||||||
import { Store } from 'n3';
|
import { Store } from 'n3';
|
||||||
import type { BaseQuad } from 'rdf-js';
|
|
||||||
import { Algebra } from 'sparqlalgebrajs';
|
import { Algebra } from 'sparqlalgebrajs';
|
||||||
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
|
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
|
||||||
import type { Patch } from '../../http/representation/Patch';
|
import type { Patch } from '../../http/representation/Patch';
|
||||||
@ -50,6 +49,10 @@ export class SparqlUpdatePatcher extends RepresentationPatcher {
|
|||||||
return representation ?? new BasicRepresentation([], identifier, INTERNAL_QUADS, false);
|
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);
|
this.validateUpdate(op);
|
||||||
|
|
||||||
return this.patch(input);
|
return this.patch(input);
|
||||||
@ -116,20 +119,8 @@ export class SparqlUpdatePatcher extends RepresentationPatcher {
|
|||||||
* Apply the given algebra operation to the given identifier.
|
* Apply the given algebra operation to the given identifier.
|
||||||
*/
|
*/
|
||||||
private async patch({ identifier, patch, representation }: RepresentationPatcherInput): Promise<Representation> {
|
private async patch({ identifier, patch, representation }: RepresentationPatcherInput): Promise<Representation> {
|
||||||
let result: Store<BaseQuad>;
|
const result = representation ? await readableToQuads(representation.data) : new Store();
|
||||||
let metadata: RepresentationMetadata;
|
this.logger.debug(`${result.size} quads in ${identifier.path}.`);
|
||||||
|
|
||||||
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>();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the query through Comunica
|
// Run the query through Comunica
|
||||||
const sparql = await readableToString(patch.data);
|
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}.`);
|
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);
|
return new BasicRepresentation(result.match() as unknown as Readable, metadata, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 APPLICATION_X_WWW_FORM_URLENCODED = 'application/x-www-form-urlencoded';
|
||||||
export const TEXT_HTML = 'text/html';
|
export const TEXT_HTML = 'text/html';
|
||||||
export const TEXT_MARKDOWN = 'text/markdown';
|
export const TEXT_MARKDOWN = 'text/markdown';
|
||||||
|
export const TEXT_N3 = 'text/n3';
|
||||||
export const TEXT_TURTLE = 'text/turtle';
|
export const TEXT_TURTLE = 'text/turtle';
|
||||||
|
|
||||||
// Internal content types (not exposed over HTTP)
|
// Internal content types (not exposed over HTTP)
|
||||||
|
@ -6,6 +6,13 @@ import type { Quad } from 'rdf-js';
|
|||||||
import type { Guarded } from './GuardedStream';
|
import type { Guarded } from './GuardedStream';
|
||||||
import { guardedStreamFrom, pipeSafely } from './StreamUtil';
|
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> {
|
export function serializeQuads(quads: Quad[], contentType?: string): Guarded<Readable> {
|
||||||
return pipeSafely(guardedStreamFrom(quads), new StreamWriter({ format: contentType }));
|
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[]> {
|
export async function parseQuads(readable: Guarded<Readable>, options: ParserOptions = {}): Promise<Quad[]> {
|
||||||
return arrayifyStream(pipeSafely(readable, new StreamParser(options)));
|
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;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
@ -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#',
|
export const SOLID = createUriAndTermNamespace('http://www.w3.org/ns/solid/terms#',
|
||||||
|
'deletes',
|
||||||
|
'inserts',
|
||||||
'oidcIssuer',
|
'oidcIssuer',
|
||||||
'oidcIssuerRegistrationToken',
|
'oidcIssuerRegistrationToken',
|
||||||
'oidcRegistration',
|
'oidcRegistration',
|
||||||
|
'where',
|
||||||
|
|
||||||
|
'InsertDeletePatch',
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SOLID_ERROR = createUriAndTermNamespace('urn:npm:solid:community-server:error:',
|
export const SOLID_ERROR = createUriAndTermNamespace('urn:npm:solid:community-server:error:',
|
||||||
|
15
src/util/errors/UnprocessableEntityHttpError.ts
Normal file
15
src/util/errors/UnprocessableEntityHttpError.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -1,11 +1,15 @@
|
|||||||
import { NotImplementedHttpError } from '../errors/NotImplementedHttpError';
|
import { NotImplementedHttpError } from '../errors/NotImplementedHttpError';
|
||||||
import { AsyncHandler } from './AsyncHandler';
|
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.
|
* 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 methods: string[];
|
||||||
private readonly source: AsyncHandler<TIn, TOut>;
|
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> {
|
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(
|
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);
|
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> {
|
public async handle(input: TIn): Promise<TOut> {
|
||||||
return this.source.handle(input);
|
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.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,9 @@ module.exports = {
|
|||||||
'unicorn/no-useless-undefined': 'off',
|
'unicorn/no-useless-undefined': 'off',
|
||||||
'no-process-env': '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
|
// We are not using Mocha
|
||||||
'mocha/no-exports': 'off',
|
'mocha/no-exports': 'off',
|
||||||
'mocha/no-nested-tests': 'off',
|
'mocha/no-nested-tests': 'off',
|
||||||
|
398
test/integration/N3Patch.test.ts
Normal file
398
test/integration/N3Patch.test.ts
Normal 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.' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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 ]));
|
||||||
|
});
|
||||||
|
});
|
204
test/unit/http/input/body/N3PatchBodyParser.test.ts
Normal file
204
test/unit/http/input/body/N3PatchBodyParser.test.ts
Normal 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.');
|
||||||
|
});
|
||||||
|
});
|
155
test/unit/storage/patch/N3Patcher.test.ts
Normal file
155
test/unit/storage/patch/N3Patcher.test.ts
Normal 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')),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
@ -1,6 +1,6 @@
|
|||||||
import 'jest-rdf';
|
import 'jest-rdf';
|
||||||
import { literal, namedNode, quad } from '@rdfjs/data-model';
|
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';
|
import { guardedStreamFrom, readableToString } from '../../../src/util/StreamUtil';
|
||||||
|
|
||||||
describe('QuadUtil', (): void => {
|
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')),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -10,6 +10,7 @@ import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplemen
|
|||||||
import { PayloadHttpError } from '../../../../src/util/errors/PayloadHttpError';
|
import { PayloadHttpError } from '../../../../src/util/errors/PayloadHttpError';
|
||||||
import { PreconditionFailedHttpError } from '../../../../src/util/errors/PreconditionFailedHttpError';
|
import { PreconditionFailedHttpError } from '../../../../src/util/errors/PreconditionFailedHttpError';
|
||||||
import { UnauthorizedHttpError } from '../../../../src/util/errors/UnauthorizedHttpError';
|
import { UnauthorizedHttpError } from '../../../../src/util/errors/UnauthorizedHttpError';
|
||||||
|
import { UnprocessableEntityHttpError } from '../../../../src/util/errors/UnprocessableEntityHttpError';
|
||||||
import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError';
|
import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError';
|
||||||
|
|
||||||
// Only used to make typings easier in the tests
|
// Only used to make typings easier in the tests
|
||||||
@ -30,6 +31,7 @@ describe('HttpError', (): void => {
|
|||||||
[ 'PreconditionFailedHttpError', 412, PreconditionFailedHttpError ],
|
[ 'PreconditionFailedHttpError', 412, PreconditionFailedHttpError ],
|
||||||
[ 'PayloadHttpError', 413, PayloadHttpError ],
|
[ 'PayloadHttpError', 413, PayloadHttpError ],
|
||||||
[ 'UnsupportedMediaTypeHttpError', 415, UnsupportedMediaTypeHttpError ],
|
[ 'UnsupportedMediaTypeHttpError', 415, UnsupportedMediaTypeHttpError ],
|
||||||
|
[ 'UnprocessableEntityHttpError', 422, UnprocessableEntityHttpError ],
|
||||||
[ 'InternalServerError', 500, InternalServerError ],
|
[ 'InternalServerError', 500, InternalServerError ],
|
||||||
[ 'NotImplementedHttpError', 501, NotImplementedHttpError ],
|
[ 'NotImplementedHttpError', 501, NotImplementedHttpError ],
|
||||||
];
|
];
|
||||||
|
@ -11,7 +11,7 @@ describe('A MethodFilterHandler', (): void => {
|
|||||||
const result = 'RESULT';
|
const result = 'RESULT';
|
||||||
let operation: Operation;
|
let operation: Operation;
|
||||||
let source: jest.Mocked<AsyncHandler<Operation, string>>;
|
let source: jest.Mocked<AsyncHandler<Operation, string>>;
|
||||||
let handler: MethodFilterHandler<Operation, string>;
|
let handler: MethodFilterHandler<any, string>;
|
||||||
|
|
||||||
beforeEach(async(): Promise<void> => {
|
beforeEach(async(): Promise<void> => {
|
||||||
operation = {
|
operation = {
|
||||||
@ -45,6 +45,17 @@ describe('A MethodFilterHandler', (): void => {
|
|||||||
expect(source.canHandle).toHaveBeenLastCalledWith(operation);
|
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> => {
|
it('calls the source extractor.', async(): Promise<void> => {
|
||||||
await expect(handler.handle(operation)).resolves.toBe(result);
|
await expect(handler.handle(operation)).resolves.toBe(result);
|
||||||
expect(source.handle).toHaveBeenLastCalledWith(operation);
|
expect(source.handle).toHaveBeenLastCalledWith(operation);
|
||||||
|
@ -17,7 +17,7 @@ export async function getResource(url: string,
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.headers.get('link')).toContain(`<${LDP.Resource}>; rel="type"`);
|
expect(response.headers.get('link')).toContain(`<${LDP.Resource}>; rel="type"`);
|
||||||
expect(response.headers.get('link')).toContain(`<${url}.acl>; rel="acl"`);
|
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');
|
expect(response.headers.get('ms-author-via')).toBe('SPARQL');
|
||||||
|
|
||||||
if (isContainer) {
|
if (isContainer) {
|
||||||
|
@ -11,6 +11,7 @@ const portNames = [
|
|||||||
'LpdHandlerWithAuth',
|
'LpdHandlerWithAuth',
|
||||||
'LpdHandlerWithoutAuth',
|
'LpdHandlerWithoutAuth',
|
||||||
'Middleware',
|
'Middleware',
|
||||||
|
'N3Patch',
|
||||||
'PodCreation',
|
'PodCreation',
|
||||||
'RedisResourceLocker',
|
'RedisResourceLocker',
|
||||||
'RestrictedIdentity',
|
'RestrictedIdentity',
|
||||||
|
Loading…
x
Reference in New Issue
Block a user