From d4f70d9c59fd5eaa767f7d862b8117365cd28e8e Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 10 Jun 2020 11:41:15 +0200 Subject: [PATCH] feat: add simple body parser --- .eslintignore | 2 + .eslintrc.js | 1 + .gitignore | 1 + external-types/arrayifyStream.d.ts | 6 ++ external-types/streamifyArray.d.ts | 6 ++ jest.config.js | 1 + package-lock.json | 71 +++++++++++++++++++ package.json | 7 +- src/ldp/http/SimpleBodyParser.ts | 52 ++++++++++++++ .../errors/UnsupportedMediaTypeHttpError.ts | 7 ++ test/unit/ldp/http/SimpleBodyParser.test.ts | 58 +++++++++++++++ tsconfig.json | 2 + 12 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 external-types/arrayifyStream.d.ts create mode 100644 external-types/streamifyArray.d.ts create mode 100644 src/ldp/http/SimpleBodyParser.ts create mode 100644 src/util/errors/UnsupportedMediaTypeHttpError.ts create mode 100644 test/unit/ldp/http/SimpleBodyParser.test.ts diff --git a/.eslintignore b/.eslintignore index 32b2c8f87e..3e87ee9f3a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,3 +7,5 @@ coverage **/*.js **/*.d.ts **/*.js.map + +!external-types/*.d.ts \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index b938bda822..2951a474a1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -16,6 +16,7 @@ module.exports = { '@typescript-eslint/space-before-function-paren': [ 'error', 'never' ], 'class-methods-use-this': 'off', 'comma-dangle': ['error', 'always-multiline'], + 'dot-location': ['error', 'property'], 'lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }], 'padding-line-between-statements': 'off', 'tsdoc/syntax': 'error', diff --git a/.gitignore b/.gitignore index 3391f006b4..1a778c5341 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ coverage !.eslintrc.js !test/eslintrc.js !jest.config.js +!external-types/*.d.ts diff --git a/external-types/arrayifyStream.d.ts b/external-types/arrayifyStream.d.ts new file mode 100644 index 0000000000..5fdb193674 --- /dev/null +++ b/external-types/arrayifyStream.d.ts @@ -0,0 +1,6 @@ +declare module 'arrayify-stream' { + import { Readable } from 'stream'; + + function arrayifyStream(input: Readable): Promise; + export = arrayifyStream; +} diff --git a/external-types/streamifyArray.d.ts b/external-types/streamifyArray.d.ts new file mode 100644 index 0000000000..6b062df7b3 --- /dev/null +++ b/external-types/streamifyArray.d.ts @@ -0,0 +1,6 @@ +declare module 'streamify-array' { + import { Readable } from 'stream'; + + function streamifyArray(input: any[]): Readable; + export = streamifyArray; +} diff --git a/jest.config.js b/jest.config.js index 345d28032a..7e3071cc20 100644 --- a/jest.config.js +++ b/jest.config.js @@ -13,6 +13,7 @@ module.exports = { "js" ], "testEnvironment": "node", + "setupFilesAfterEnv": ["jest-rdf"], "collectCoverage": true, "coveragePathIgnorePatterns": [ "/node_modules/" diff --git a/package-lock.json b/package-lock.json index ed93f09675..67172f9bdb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -862,6 +862,15 @@ "integrity": "sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==", "dev": true }, + "@types/n3": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@types/n3/-/n3-1.1.6.tgz", + "integrity": "sha512-ZUZsoA13IkJOLZCXG9bgZGuzvGqhPQJ3CisPvZJ5Yfi+RxVtDCcezyTRw7olArfmxpD0UuEOtrIP/1PUCfkEBw==", + "requires": { + "@types/node": "*", + "@types/rdf-js": "*" + } + }, "@types/node": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.0.1.tgz", @@ -1106,6 +1115,12 @@ "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", "dev": true }, + "arrayify-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/arrayify-stream/-/arrayify-stream-1.0.0.tgz", + "integrity": "sha512-RP80ep76Lbew2wWN5ogrl2NluTnBVYYh2K3NNCcWfcmmUB7nBcNBctiJeEZAixp3I1vQ9H88iHZ9MbHSdkuupQ==", + "dev": true + }, "asn1": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", @@ -3603,6 +3618,16 @@ "integrity": "sha512-pgFw2tm54fzgYvc/OHrnysABEObZCUNFnhjoRjaVOCN8NYc032/gVjPaHD4Aq6ApkSieWtfKAFQtmDKAmhupnQ==", "dev": true }, + "jest-rdf": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/jest-rdf/-/jest-rdf-1.5.0.tgz", + "integrity": "sha512-B7kaGHC/YZ+KHRM0W94FWLaIOAPqsdVEE2Ni3U84aGZnBlF4U+1lmJvuvDABlAm3E9Smh7HdaQgEQJRj29biew==", + "dev": true, + "requires": { + "rdf-isomorphic": "^1.1.0", + "rdf-string": "^1.3.1" + } + }, "jest-regex-util": { "version": "26.0.0", "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-26.0.0.tgz", @@ -3992,6 +4017,12 @@ "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", "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=", + "dev": true + }, "lodash.zip": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", @@ -4146,6 +4177,11 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, + "n3": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.3.7.tgz", + "integrity": "sha512-IREbOmZyTlc34vxlp31ECT5jliRNZqHg3THhzrVd5bcSWGto5xsN8fF5xWKYXZr8TdZX+GXFkCxttTKM1N3JZg==" + }, "nanomatch": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", @@ -4642,6 +4678,35 @@ "integrity": "sha512-pVzZdDpWwWqEVVLshWUHjNwuVP7SfcmPraYuqocJp1yo2U1R7P+5QAfDhdItkuoGqIBnBYrtPp7rEPqDn9HlZA==", "dev": true }, + "rdf-isomorphic": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/rdf-isomorphic/-/rdf-isomorphic-1.1.0.tgz", + "integrity": "sha512-E4E3RJJ0RBBCDGJ6cx7httfnV0Z2xcdF81epe581xSvPsCe42qWYysZ6DKTkBTrmMjNeScNnDkjubLS5RSODtw==", + "dev": true, + "requires": { + "rdf-string": "^1.3.1", + "rdf-terms": "^1.4.0" + } + }, + "rdf-string": { + "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" + } + }, + "rdf-terms": { + "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" + } + }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -5489,6 +5554,12 @@ "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=", "dev": true }, + "streamify-array": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/streamify-array/-/streamify-array-1.0.1.tgz", + "integrity": "sha512-ZnswaBcC6B1bhPLSQOlC6CdaDUSzU0wr2lvvHpbHNms8V7+DLd8uEAzDAWpsjxbFkijBHhuObFO/qqu52DZUMA==", + "dev": true + }, "string-length": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.1.tgz", diff --git a/package.json b/package.json index 43b3f2f497..256f8521f7 100644 --- a/package.json +++ b/package.json @@ -22,18 +22,23 @@ ], "dependencies": { "@rdfjs/data-model": "^1.1.2", + "@types/n3": "^1.1.6", "@types/node": "^14.0.1", - "@types/rdf-js": "^3.0.0" + "@types/rdf-js": "^3.0.0", + "n3": "^1.3.7" }, "devDependencies": { "@types/jest": "^25.2.1", "@typescript-eslint/eslint-plugin": "^2.33.0", "@typescript-eslint/parser": "^2.33.0", + "arrayify-stream": "^1.0.0", "eslint": "^7.0.0", "eslint-config-es": "^3.19.61", "eslint-plugin-tsdoc": "^0.2.4", "husky": "^4.2.5", "jest": "^26.0.1", + "jest-rdf": "^1.5.0", + "streamify-array": "^1.0.1", "ts-jest": "^26.0.0", "typescript": "^3.9.2" } diff --git a/src/ldp/http/SimpleBodyParser.ts b/src/ldp/http/SimpleBodyParser.ts new file mode 100644 index 0000000000..077c4b3e5a --- /dev/null +++ b/src/ldp/http/SimpleBodyParser.ts @@ -0,0 +1,52 @@ +import { BodyParser } from './BodyParser'; +import { HttpRequest } from '../../server/HttpRequest'; +import { Quad } from 'rdf-js'; +import { QuadRepresentation } from '../representation/QuadRepresentation'; +import { RepresentationMetadata } from '../representation/RepresentationMetadata'; +import { StreamParser } from 'n3'; +import { TypedReadable } from '../../util/TypedReadable'; +import { UnsupportedMediaTypeHttpError } from '../../util/errors/UnsupportedMediaTypeHttpError'; +import 'jest-rdf'; + +export class SimpleBodyParser extends BodyParser { + private static readonly contentTypes = [ + 'application/n-quads', + 'application/trig', + 'application/n-triples', + 'text/turtle', + 'text/n3', + ]; + + public async canHandle(input: HttpRequest): Promise { + const contentType = input.headers['content-type']; + + if (contentType && !SimpleBodyParser.contentTypes.some((type): boolean => contentType.includes(type))) { + throw new UnsupportedMediaTypeHttpError('This parser only supports RDF data.'); + } + } + + public async handle(input: HttpRequest): Promise { + const contentType = input.headers['content-type']; + + if (!contentType) { + return undefined; + } + + const specificType = contentType.split(';')[0]; + + const metadata: RepresentationMetadata = { + raw: [], + profiles: [], + contentType: specificType, + }; + + // StreamParser is a Readable but typings are incorrect at time of writing + const quads: TypedReadable = input.pipe(new StreamParser()) as unknown as TypedReadable; + + return { + dataType: 'quad', + data: quads, + metadata, + }; + } +} diff --git a/src/util/errors/UnsupportedMediaTypeHttpError.ts b/src/util/errors/UnsupportedMediaTypeHttpError.ts new file mode 100644 index 0000000000..a8ae555f97 --- /dev/null +++ b/src/util/errors/UnsupportedMediaTypeHttpError.ts @@ -0,0 +1,7 @@ +import { HttpError } from './HttpError'; + +export class UnsupportedMediaTypeHttpError extends HttpError { + public constructor(message?: string) { + super(415, 'UnsupportedHttpError', message); + } +} diff --git a/test/unit/ldp/http/SimpleBodyParser.test.ts b/test/unit/ldp/http/SimpleBodyParser.test.ts new file mode 100644 index 0000000000..8100d29540 --- /dev/null +++ b/test/unit/ldp/http/SimpleBodyParser.test.ts @@ -0,0 +1,58 @@ +import arrayifyStream from 'arrayify-stream'; +import { HttpRequest } from '../../../../src/server/HttpRequest'; +import { SimpleBodyParser } from '../../../../src/ldp/http/SimpleBodyParser'; +import streamifyArray from 'streamify-array'; +import { StreamParser } from 'n3'; +import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError'; +import { namedNode, triple } from '@rdfjs/data-model'; + +const contentTypes = [ + 'application/n-quads', + 'application/trig', + 'application/n-triples', + 'text/turtle', + 'text/n3', +]; + +describe('A SimpleBodyparser', (): void => { + const bodyParser = new SimpleBodyParser(); + + it('rejects input with unsupported content type.', async(): Promise => { + await expect(bodyParser.canHandle({ headers: { 'content-type': 'application/rdf+xml' }} as HttpRequest)) + .rejects.toThrow(new UnsupportedMediaTypeHttpError('This parser only supports RDF data.')); + }); + + it('accepts input with no content type.', async(): Promise => { + await expect(bodyParser.canHandle({ headers: { }} as HttpRequest)).resolves.toBeUndefined(); + }); + + it('accepts turtle and similar content types.', async(): Promise => { + for (const type of contentTypes) { + await expect(bodyParser.canHandle({ headers: { 'content-type': type }} as HttpRequest)).resolves.toBeUndefined(); + } + }); + + it('returns empty output if there was no content-type.', async(): Promise => { + await expect(bodyParser.handle({ headers: { }} as HttpRequest)).resolves.toBeUndefined(); + }); + + it('returns a stream of quads if there was data.', async(): Promise => { + const input = streamifyArray([ ' .' ]) as HttpRequest; + input.headers = { 'content-type': 'text/turtle' }; + const result = await bodyParser.handle(input); + expect(result).toEqual({ + data: expect.any(StreamParser), + dataType: 'quad', + metadata: { + contentType: 'text/turtle', + profiles: [], + raw: [], + }, + }); + await expect(arrayifyStream(result.data)).resolves.toEqualRdfQuadArray([ triple( + namedNode('http://test.com/s'), + namedNode('http://test.com/p'), + namedNode('http://test.com/o'), + ) ]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 094a0ab2b7..4d44adbbd4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "newLine": "lf", "alwaysStrict": true, "declaration": true, + "esModuleInterop": true, "inlineSources": true, "noImplicitAny": true, "noImplicitThis": true, @@ -14,6 +15,7 @@ "stripInternal": true }, "include": [ + "external-types/**/*.ts", "src/**/*.ts", "test/**/*.ts" ],