Skip to content

Commit

Permalink
feat: Add support for N3 Patch
Browse files Browse the repository at this point in the history
  • Loading branch information
joachimvh committed Jan 25, 2022
1 parent 1afed65 commit a9941eb
Show file tree
Hide file tree
Showing 28 changed files with 1,331 additions and 46 deletions.
1 change: 1 addition & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 14 additions & 1 deletion config/ldp/handler/components/request-parser.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
]
}
}
]
}
2 changes: 1 addition & 1 deletion config/ldp/metadata-writer/writers/constant.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions config/ldp/modes/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"source": {
"@type": "WaterfallHandler",
"handlers": [
{ "@type": "N3PatchModesExtractor" },
{ "@type": "SparqlUpdateModesExtractor" },
{
"@type": "StaticThrowHandler",
Expand Down
13 changes: 11 additions & 2 deletions config/storage/middleware/stores/patching.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 changes: 11 additions & 20 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions src/authorization/permissions/N3PatchModesExtractor.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 138 additions & 0 deletions src/http/input/body/N3PatchBodyParser.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 18 additions & 0 deletions src/http/representation/N3Patch.ts
Original file line number Diff line number Diff line change
@@ -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);
}
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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';
Expand Down
Loading

0 comments on commit a9941eb

Please sign in to comment.