From 95c65c86a70b63929ac902e005d809c4621bd759 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 22 Jul 2020 13:40:47 +0200 Subject: [PATCH] feat: Add BodyParser for SPARQL updates --- package-lock.json | 41 ++++++++++++---- package.json | 2 + src/ldp/http/Patch.ts | 7 ++- src/ldp/http/SimpleSparqlUpdateBodyParser.ts | 47 +++++++++++++++++++ src/ldp/http/SparqlUpdatePatch.ts | 12 +++++ src/util/Util.ts | 5 ++ .../http/SimpleSparqlUpdateBodyParser.test.ts | 41 ++++++++++++++++ 7 files changed, 146 insertions(+), 9 deletions(-) create mode 100644 src/ldp/http/SimpleSparqlUpdateBodyParser.ts create mode 100644 src/ldp/http/SparqlUpdatePatch.ts create mode 100644 test/unit/ldp/http/SimpleSparqlUpdateBodyParser.test.ts diff --git a/package-lock.json b/package-lock.json index 13a234b3e..894aa8156 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3080,8 +3080,7 @@ "fast-deep-equal": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz", - "integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==", - "dev": true + "integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==" }, "fast-json-stable-stringify": { "version": "2.1.0", @@ -4747,8 +4746,7 @@ "lodash.uniqwith": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniqwith/-/lodash.uniqwith-4.5.0.tgz", - "integrity": "sha1-egy/ZfQ7WShiWp1NDcVLGMrcfvM=", - "dev": true + "integrity": "sha1-egy/ZfQ7WShiWp1NDcVLGMrcfvM=" }, "lodash.zip": { "version": "4.2.0", @@ -4889,8 +4887,7 @@ "minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", - "dev": true + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" }, "mixin-deep": { "version": "1.3.2", @@ -5672,7 +5669,6 @@ "version": "1.4.2", "resolved": "https://registry.npmjs.org/rdf-string/-/rdf-string-1.4.2.tgz", "integrity": "sha512-74yYjS0W4N3nYDGbXBZrNsqDmhBTjqChTETO9heC2G2M3iMYaIPtEfUikNsBWUj4+4bIKyqL7vAntWBTfJpFFA==", - "dev": true, "requires": { "@rdfjs/data-model": "^1.1.1" } @@ -5681,7 +5677,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.5.1.tgz", "integrity": "sha512-dDhpUYxTAOWKT3Ln93A5k5UB5SftG8bPAzeZEjGeP4e7eboMhITUTDks8HDmUt9X1P+HpfnY/o6VSTSpf3Advw==", - "dev": true, "requires": { "@rdfjs/data-model": "^1.1.1", "lodash.uniqwith": "^4.5.0" @@ -6551,6 +6546,36 @@ "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=", "dev": true }, + "sparqlalgebrajs": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/sparqlalgebrajs/-/sparqlalgebrajs-2.3.1.tgz", + "integrity": "sha512-zJ9EEX2BtHjdiSQoWP7fKt2IqD4zi24n3CQtRGOIzh8Hyj2JFMtje68CweaIJgQLN5kNYpNx/VT2F6nxpeIRJg==", + "requires": { + "@rdfjs/data-model": "^1.1.2", + "fast-deep-equal": "^3.1.1", + "minimist": "^1.2.5", + "rdf-string": "^1.3.1", + "sparqljs": "^3.0.1" + } + }, + "sparqljs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sparqljs/-/sparqljs-3.0.2.tgz", + "integrity": "sha512-qmXKWx0ENeLUlvo0tgzcVfnvZnOBkyySvysIf6Y3HgWFKmu1bSrDB9K8siQYJO7jnSihoebtAPv89jvA6by/iw==", + "requires": { + "n3": "^1.5.0" + }, + "dependencies": { + "n3": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.5.0.tgz", + "integrity": "sha512-k4R/EOMnnRYFt+hXgqyKOHjzmshaLuHUFgrz5nsp9nAojCZuAHrro/DsIM2tS0Bgx6ed7DM5Ks3q2teJ8n7HnQ==", + "requires": { + "queue-microtask": "^1.1.2" + } + } + } + }, "spdx-correct": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.0.tgz", diff --git a/package.json b/package.json index 8efabca90..6ac7d2a34 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "cors": "^2.8.5", "express": "^4.17.1", "n3": "^1.4.0", + "rdf-terms": "^1.5.1", + "sparqlalgebrajs": "^2.3.1", "yargs": "^15.4.1" }, "devDependencies": { diff --git a/src/ldp/http/Patch.ts b/src/ldp/http/Patch.ts index b5a13a73e..47724298d 100644 --- a/src/ldp/http/Patch.ts +++ b/src/ldp/http/Patch.ts @@ -3,4 +3,9 @@ import { Representation } from '../representation/Representation'; /** * Represents the changes needed for a PATCH request. */ -export interface Patch extends Representation {} +export interface Patch extends Representation { + /** + * The raw body of the PATCH request. + */ + raw: string; +} diff --git a/src/ldp/http/SimpleSparqlUpdateBodyParser.ts b/src/ldp/http/SimpleSparqlUpdateBodyParser.ts new file mode 100644 index 000000000..22b73919f --- /dev/null +++ b/src/ldp/http/SimpleSparqlUpdateBodyParser.ts @@ -0,0 +1,47 @@ +import { BodyParser } from './BodyParser'; +import { HttpRequest } from '../../server/HttpRequest'; +import { Readable } from 'stream'; +import { readableToString } from '../../util/Util'; +import { SparqlUpdatePatch } from './SparqlUpdatePatch'; +import { translate } from 'sparqlalgebrajs'; +import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError'; +import { UnsupportedMediaTypeHttpError } from '../../util/errors/UnsupportedMediaTypeHttpError'; + +/** + * {@link BodyParser} that supports `application/sparql-update` content. + * Will convert the incoming update string to algebra in a {@link SparqlUpdatePatch}. + * Simple since metadata parsing is not yet implemented. + */ +export class SimpleSparqlUpdateBodyParser extends BodyParser { + public async canHandle(input: HttpRequest): Promise { + const contentType = input.headers['content-type']; + + if (!contentType || contentType !== 'application/sparql-update') { + throw new UnsupportedMediaTypeHttpError('This parser only supports SPARQL UPDATE data.'); + } + } + + public async handle(input: HttpRequest): Promise { + try { + const sparql = await readableToString(input); + const algebra = translate(sparql, { quads: true }); + + // Prevent body from being requested again + return { + algebra, + dataType: 'sparql-algebra', + raw: sparql, + get data(): Readable { + throw new Error('Body already parsed'); + }, + metadata: { + raw: [], + profiles: [], + contentType: 'application/sparql-update', + }, + }; + } catch (error) { + throw new UnsupportedHttpError(error); + } + } +} diff --git a/src/ldp/http/SparqlUpdatePatch.ts b/src/ldp/http/SparqlUpdatePatch.ts new file mode 100644 index 000000000..1165df0b5 --- /dev/null +++ b/src/ldp/http/SparqlUpdatePatch.ts @@ -0,0 +1,12 @@ +import { Patch } from './Patch'; +import { Update } from 'sparqlalgebrajs/lib/algebra'; + +/** + * A specific type of {@link Patch} corresponding to a SPARQL update. + */ +export interface SparqlUpdatePatch extends Patch { + /** + * Algebra corresponding to the SPARQL update. + */ + algebra: Update; +} diff --git a/src/util/Util.ts b/src/util/Util.ts index 0083ed726..f8b461013 100644 --- a/src/util/Util.ts +++ b/src/util/Util.ts @@ -1,3 +1,6 @@ +import arrayifyStream from 'arrayify-stream'; +import { Readable } from 'stream'; + /** * Makes sure the input path has exactly 1 slash at the end. * Multiple slashes will get merged into one. @@ -8,3 +11,5 @@ * @returns The potentially changed path. */ export const ensureTrailingSlash = (path: string): string => path.replace(/\/*$/u, '/'); + +export const readableToString = async(stream: Readable): Promise => (await arrayifyStream(stream)).join(''); diff --git a/test/unit/ldp/http/SimpleSparqlUpdateBodyParser.test.ts b/test/unit/ldp/http/SimpleSparqlUpdateBodyParser.test.ts new file mode 100644 index 000000000..267de1410 --- /dev/null +++ b/test/unit/ldp/http/SimpleSparqlUpdateBodyParser.test.ts @@ -0,0 +1,41 @@ +import { Algebra } from 'sparqlalgebrajs'; +import { HttpRequest } from '../../../../src/server/HttpRequest'; +import { SimpleSparqlUpdateBodyParser } from '../../../../src/ldp/http/SimpleSparqlUpdateBodyParser'; +import streamifyArray from 'streamify-array'; +import { UnsupportedHttpError } from '../../../../src/util/errors/UnsupportedHttpError'; +import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError'; +import { namedNode, quad } from '@rdfjs/data-model'; + +describe('A SimpleSparqlUpdateBodyParser', (): void => { + const bodyParser = new SimpleSparqlUpdateBodyParser(); + + it('only accepts application/sparql-update content.', async(): Promise => { + await expect(bodyParser.canHandle({ headers: {}} as HttpRequest)).rejects.toThrow(UnsupportedMediaTypeHttpError); + await expect(bodyParser.canHandle({ headers: { 'content-type': 'text/plain' }} as HttpRequest)).rejects.toThrow(UnsupportedMediaTypeHttpError); + await expect(bodyParser.canHandle({ headers: { 'content-type': 'application/sparql-update' }} as HttpRequest)).resolves.toBeUndefined(); + }); + + it('errors when handling invalid SPARQL updates.', async(): Promise => { + await expect(bodyParser.handle(streamifyArray([ 'VERY INVALID UPDATE' ]) as HttpRequest)).rejects.toThrow(UnsupportedHttpError); + }); + + it('converts SPARQL updates to algebra.', async(): Promise => { + const result = await bodyParser.handle(streamifyArray( + [ 'DELETE DATA { }' ], + ) as HttpRequest); + expect(result.algebra.type).toBe(Algebra.types.DELETE_INSERT); + expect((result.algebra as Algebra.DeleteInsert).delete).toBeRdfIsomorphic([ quad( + namedNode('http://test.com/s'), + namedNode('http://test.com/p'), + namedNode('http://test.com/o'), + ) ]); + expect(result.dataType).toBe('sparql-algebra'); + expect(result.raw).toBe('DELETE DATA { }'); + expect(result.metadata).toEqual({ + raw: [], + profiles: [], + contentType: 'application/sparql-update', + }); + expect((): any => result.data).toThrow('Body already parsed'); + }); +});