diff --git a/config/config-default.json b/config/config-default.json index 905098c314..02e5320e29 100644 --- a/config/config-default.json +++ b/config/config-default.json @@ -5,6 +5,7 @@ "files-scs:config/presets/http.json", "files-scs:config/presets/ldp.json", "files-scs:config/presets/ldp/credentials-extractor.json", + "files-scs:config/presets/ldp/metadata-handler.json", "files-scs:config/presets/ldp/operation-handler.json", "files-scs:config/presets/ldp/permissions-extractor.json", "files-scs:config/presets/ldp/request-parser.json", diff --git a/config/presets/ldp/metadata-handler.json b/config/presets/ldp/metadata-handler.json new file mode 100644 index 0000000000..4763a12c69 --- /dev/null +++ b/config/presets/ldp/metadata-handler.json @@ -0,0 +1,20 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", + "@graph": [ + { + "@id": "urn:solid-server:default:MetadataHandler", + "@type": "BasicMetadataHandler", + "BasicMetadataHandler:_parsers": [ + { + "@type": "ContentTypeParser" + }, + { + "@type": "LinkTypeParser" + }, + { + "@type": "SlugParser" + } + ] + } + ] +} diff --git a/config/presets/ldp/request-parser.json b/config/presets/ldp/request-parser.json index f3422cc132..f339e4b81e 100644 --- a/config/presets/ldp/request-parser.json +++ b/config/presets/ldp/request-parser.json @@ -14,10 +14,16 @@ "@type": "CompositeAsyncHandler", "CompositeAsyncHandler:_handlers": [ { - "@type": "SparqlUpdateBodyParser" + "@type": "SparqlUpdateBodyParser", + "SparqlUpdateBodyParser:_metadataHandler": { + "@id": "urn:solid-server:default:MetadataHandler" + } }, { - "@type": "RawBodyParser" + "@type": "RawBodyParser", + "RawBodyParser:_metadataHandler": { + "@id": "urn:solid-server:default:MetadataHandler" + } } ] } diff --git a/index.ts b/index.ts index 98e7446e3c..b104289150 100644 --- a/index.ts +++ b/index.ts @@ -14,6 +14,14 @@ export * from './src/authorization/WebAclAuthorizer'; export * from './src/init/CliRunner'; export * from './src/init/Setup'; +// LDP/HTTP/Metadata +export * from './src/ldp/http/metadata/BasicMetadataHandler'; +export * from './src/ldp/http/metadata/ContentTypeParser'; +export * from './src/ldp/http/metadata/LinkTypeParser'; +export * from './src/ldp/http/metadata/MetadataHandler'; +export * from './src/ldp/http/metadata/MetadataParser'; +export * from './src/ldp/http/metadata/SlugParser'; + // LDP/HTTP export * from './src/ldp/http/AcceptPreferenceParser'; export * from './src/ldp/http/BasicRequestParser'; diff --git a/src/ldp/http/RawBodyParser.ts b/src/ldp/http/RawBodyParser.ts index 9f652a86e9..abd5c932d0 100644 --- a/src/ldp/http/RawBodyParser.ts +++ b/src/ldp/http/RawBodyParser.ts @@ -1,9 +1,8 @@ import type { HttpRequest } from '../../server/HttpRequest'; import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError'; -import { CONTENT_TYPE, HTTP, RDF } from '../../util/UriConstants'; import type { Representation } from '../representation/Representation'; -import { RepresentationMetadata } from '../representation/RepresentationMetadata'; import { BodyParser } from './BodyParser'; +import type { MetadataHandler } from './metadata/MetadataHandler'; /** * Converts incoming {@link HttpRequest} to a Representation without any further parsing. @@ -11,6 +10,13 @@ import { BodyParser } from './BodyParser'; * Some other metadata is also generated, but this should probably be done in an external handler. */ export class RawBodyParser extends BodyParser { + private readonly metadataHandler: MetadataHandler; + + public constructor(metadataHandler: MetadataHandler) { + super(); + this.metadataHandler = metadataHandler; + } + public async canHandle(): Promise { // All content-types are supported } @@ -33,40 +39,7 @@ export class RawBodyParser extends BodyParser { return { binary: true, data: input, - metadata: this.parseMetadata(input), + metadata: await this.metadataHandler.handleSafe(input), }; } - - private parseMetadata(input: HttpRequest): RepresentationMetadata { - const contentType = /^[^;]*/u.exec(input.headers['content-type']!)![0]; - - const metadata = new RepresentationMetadata({ [CONTENT_TYPE]: contentType }); - - const { link, slug } = input.headers; - - if (slug) { - if (Array.isArray(slug)) { - throw new UnsupportedHttpError('At most 1 slug header is allowed.'); - } - metadata.set(HTTP.slug, slug); - } - - // There are similarities here to Accept header parsing so that library should become more generic probably - if (link) { - const linkArray = Array.isArray(link) ? link : [ link ]; - const parsedLinks = linkArray.map((entry): { url: string; rel: string } => { - const [ , url, rest ] = /^<([^>]*)>(.*)$/u.exec(entry) ?? []; - const [ , rel ] = /^ *; *rel="(.*)"$/u.exec(rest) ?? []; - return { url, rel }; - }); - for (const entry of parsedLinks) { - if (entry.rel === 'type') { - metadata.set(RDF.type, entry.url); - break; - } - } - } - - return metadata; - } } diff --git a/src/ldp/http/SparqlUpdateBodyParser.ts b/src/ldp/http/SparqlUpdateBodyParser.ts index 6ead8058b9..6554136f5e 100644 --- a/src/ldp/http/SparqlUpdateBodyParser.ts +++ b/src/ldp/http/SparqlUpdateBodyParser.ts @@ -1,13 +1,13 @@ import { PassThrough } from 'stream'; +import type { Algebra } from 'sparqlalgebrajs'; import { translate } from 'sparqlalgebrajs'; import type { HttpRequest } from '../../server/HttpRequest'; import { APPLICATION_SPARQL_UPDATE } from '../../util/ContentTypes'; import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError'; import { UnsupportedMediaTypeHttpError } from '../../util/errors/UnsupportedMediaTypeHttpError'; -import { CONTENT_TYPE } from '../../util/UriConstants'; -import { readableToString } from '../../util/Util'; -import { RepresentationMetadata } from '../representation/RepresentationMetadata'; +import { pipeStreamsAndErrors, readableToString } from '../../util/Util'; import { BodyParser } from './BodyParser'; +import type { MetadataHandler } from './metadata/MetadataHandler'; import type { SparqlUpdatePatch } from './SparqlUpdatePatch'; /** @@ -16,6 +16,13 @@ import type { SparqlUpdatePatch } from './SparqlUpdatePatch'; * Still needs access to a handler for parsing metadata. */ export class SparqlUpdateBodyParser extends BodyParser { + private readonly metadataHandler: MetadataHandler; + + public constructor(metadataHandler: MetadataHandler) { + super(); + this.metadataHandler = metadataHandler; + } + public async canHandle(input: HttpRequest): Promise { if (input.headers['content-type'] !== APPLICATION_SPARQL_UPDATE) { throw new UnsupportedMediaTypeHttpError('This parser only supports SPARQL UPDATE data.'); @@ -23,31 +30,30 @@ export class SparqlUpdateBodyParser extends BodyParser { } public async handle(input: HttpRequest): Promise { + // Note that readableObjectMode is only defined starting from Node 12 + // It is impossible to check if object mode is enabled in Node 10 (without accessing private variables) + const options = { objectMode: input.readableObjectMode }; + const toAlgebraStream = new PassThrough(options); + const dataCopy = new PassThrough(options); + pipeStreamsAndErrors(input, toAlgebraStream); + pipeStreamsAndErrors(input, dataCopy); + let algebra: Algebra.Operation; try { - // Note that readableObjectMode is only defined starting from Node 12 - // It is impossible to check if object mode is enabled in Node 10 (without accessing private variables) - const options = { objectMode: input.readableObjectMode }; - const toAlgebraStream = new PassThrough(options); - const dataCopy = new PassThrough(options); - input.pipe(toAlgebraStream); - input.pipe(dataCopy); const sparql = await readableToString(toAlgebraStream); - const algebra = translate(sparql, { quads: true }); - - const metadata = new RepresentationMetadata({ [CONTENT_TYPE]: APPLICATION_SPARQL_UPDATE }); - - // Prevent body from being requested again - return { - algebra, - binary: true, - data: dataCopy, - metadata, - }; + algebra = translate(sparql, { quads: true }); } catch (error: unknown) { if (error instanceof Error) { throw new UnsupportedHttpError(error.message); } throw new UnsupportedHttpError(); } + + // Prevent body from being requested again + return { + algebra, + binary: true, + data: dataCopy, + metadata: await this.metadataHandler.handleSafe(input), + }; } } diff --git a/test/configs/BasicHandlersConfig.ts b/test/configs/BasicHandlersConfig.ts index a6d88d0252..899cafb6d4 100644 --- a/test/configs/BasicHandlersConfig.ts +++ b/test/configs/BasicHandlersConfig.ts @@ -15,10 +15,14 @@ import { } from '../../index'; import type { ServerConfig } from './ServerConfig'; -import { getInMemoryResourceStore, +import { + getInMemoryResourceStore, getOperationHandler, getConvertingStore, - getPatchingStore, getBasicRequestParser } from './Util'; + getPatchingStore, + getBasicRequestParser, + getBasicMetadataHandler, +} from './Util'; /** * BasicHandlersConfig works with @@ -39,9 +43,10 @@ export class BasicHandlersConfig implements ServerConfig { } public getHttpHandler(): HttpHandler { + const metadataHandler = getBasicMetadataHandler(); const requestParser = getBasicRequestParser([ - new SparqlUpdateBodyParser(), - new RawBodyParser(), + new SparqlUpdateBodyParser(metadataHandler), + new RawBodyParser(metadataHandler), ]); const credentialsExtractor = new UnsecureWebIdExtractor(); diff --git a/test/configs/FileResourceStoreConfig.ts b/test/configs/FileResourceStoreConfig.ts index 5b091dd76f..a30a75fdec 100644 --- a/test/configs/FileResourceStoreConfig.ts +++ b/test/configs/FileResourceStoreConfig.ts @@ -12,7 +12,13 @@ import { UnsecureWebIdExtractor, } from '../../index'; import type { ServerConfig } from './ServerConfig'; -import { getFileResourceStore, getOperationHandler, getConvertingStore, getBasicRequestParser } from './Util'; +import { + getFileResourceStore, + getOperationHandler, + getConvertingStore, + getBasicRequestParser, + getBasicMetadataHandler, +} from './Util'; /** * FileResourceStoreConfig works with @@ -33,7 +39,7 @@ export class FileResourceStoreConfig implements ServerConfig { public getHttpHandler(): HttpHandler { // This is for the sake of test coverage, as it could also be just getBasicRequestParser() - const requestParser = getBasicRequestParser([ new RawBodyParser() ]); + const requestParser = getBasicRequestParser([ new RawBodyParser(getBasicMetadataHandler()) ]); const credentialsExtractor = new UnsecureWebIdExtractor(); const permissionsExtractor = new CompositeAsyncHandler([ diff --git a/test/configs/Util.ts b/test/configs/Util.ts index e98e7178a0..3ed8fbd675 100644 --- a/test/configs/Util.ts +++ b/test/configs/Util.ts @@ -1,21 +1,22 @@ import { join } from 'path'; import type { BodyParser, - HttpRequest, Operation, - Representation, RepresentationConverter, ResourceStore, ResponseDescription } from '../../index'; import { AcceptPreferenceParser, + BasicMetadataHandler, BasicRequestParser, BasicTargetExtractor, CompositeAsyncHandler, + ContentTypeParser, DeleteOperationHandler, FileResourceStore, GetOperationHandler, InMemoryResourceStore, InteractionController, + LinkTypeParser, MetadataController, PatchingStore, PatchOperationHandler, @@ -24,6 +25,7 @@ import { RawBodyParser, RepresentationConvertingStore, SingleThreadedResourceLocker, + SlugParser, SparqlUpdatePatchHandler, UrlBasedAclManager, UrlContainerManager, @@ -102,6 +104,15 @@ export const getOperationHandler = (store: ResourceStore): CompositeAsyncHandler return new CompositeAsyncHandler(handlers); }; +/** + * Creates a BasicMetadataHandler with parsers for content-type, slugs and link types. + */ +export const getBasicMetadataHandler = (): BasicMetadataHandler => new BasicMetadataHandler([ + new ContentTypeParser(), + new SlugParser(), + new LinkTypeParser(), +]); + /** * Gives a basic request parser based on some body parses. * @param bodyParsers - Optional list of body parsers, default is RawBodyParser. @@ -114,9 +125,9 @@ export const getBasicRequestParser = (bodyParsers: BodyParser[] = []): BasicRequ bodyParser = bodyParsers[0]; } else if (bodyParsers.length === 0) { // If no body parser is given (array is empty), default to RawBodyParser - bodyParser = new RawBodyParser(); + bodyParser = new RawBodyParser(getBasicMetadataHandler()); } else { - bodyParser = new CompositeAsyncHandler(bodyParsers); + bodyParser = new CompositeAsyncHandler(bodyParsers); } return new BasicRequestParser({ targetExtractor: new BasicTargetExtractor(), diff --git a/test/integration/RequestParser.test.ts b/test/integration/RequestParser.test.ts index d5abb0f47c..79cba2fca4 100644 --- a/test/integration/RequestParser.test.ts +++ b/test/integration/RequestParser.test.ts @@ -4,13 +4,15 @@ import streamifyArray from 'streamify-array'; import { AcceptPreferenceParser } from '../../src/ldp/http/AcceptPreferenceParser'; import { BasicRequestParser } from '../../src/ldp/http/BasicRequestParser'; import { BasicTargetExtractor } from '../../src/ldp/http/BasicTargetExtractor'; +import { BasicMetadataHandler } from '../../src/ldp/http/metadata/BasicMetadataHandler'; +import { ContentTypeParser } from '../../src/ldp/http/metadata/ContentTypeParser'; import { RawBodyParser } from '../../src/ldp/http/RawBodyParser'; import { RepresentationMetadata } from '../../src/ldp/representation/RepresentationMetadata'; import type { HttpRequest } from '../../src/server/HttpRequest'; describe('A BasicRequestParser with simple input parsers', (): void => { const targetExtractor = new BasicTargetExtractor(); - const bodyParser = new RawBodyParser(); + const bodyParser = new RawBodyParser(new BasicMetadataHandler([ new ContentTypeParser() ])); const preferenceParser = new AcceptPreferenceParser(); const requestParser = new BasicRequestParser({ targetExtractor, bodyParser, preferenceParser }); diff --git a/test/unit/ldp/http/RawBodyParser.test.ts b/test/unit/ldp/http/RawBodyParser.test.ts index 539a0ea7b7..26dfee40e8 100644 --- a/test/unit/ldp/http/RawBodyParser.test.ts +++ b/test/unit/ldp/http/RawBodyParser.test.ts @@ -1,14 +1,15 @@ import arrayifyStream from 'arrayify-stream'; import streamifyArray from 'streamify-array'; +import type { MetadataHandler } from '../../../../src/ldp/http/metadata/MetadataHandler'; import { RawBodyParser } from '../../../../src/ldp/http/RawBodyParser'; import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; import type { HttpRequest } from '../../../../src/server/HttpRequest'; -import { UnsupportedHttpError } from '../../../../src/util/errors/UnsupportedHttpError'; import 'jest-rdf'; -import { HTTP, RDF } from '../../../../src/util/UriConstants'; +import { StaticAsyncHandler } from '../../../util/StaticAsyncHandler'; describe('A RawBodyparser', (): void => { - const bodyParser = new RawBodyParser(); + const metadataHandler = new StaticAsyncHandler(true, new RepresentationMetadata()) as MetadataHandler; + const bodyParser = new RawBodyParser(metadataHandler); it('accepts all input.', async(): Promise => { await expect(bodyParser.canHandle()).resolves.toBeUndefined(); @@ -35,6 +36,7 @@ describe('A RawBodyparser', (): void => { }); it('returns a Representation if there was data.', async(): Promise => { + const mock = jest.spyOn(metadataHandler, 'handleSafe'); const input = streamifyArray([ ' .' ]) as HttpRequest; input.headers = { 'transfer-encoding': 'chunked', 'content-type': 'text/turtle' }; const result = (await bodyParser.handle(input))!; @@ -43,45 +45,11 @@ describe('A RawBodyparser', (): void => { data: input, metadata: expect.any(RepresentationMetadata), }); - expect(result.metadata.contentType).toEqual('text/turtle'); + expect(metadataHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(metadataHandler.handleSafe).toHaveBeenLastCalledWith(input); await expect(arrayifyStream(result.data)).resolves.toEqual( [ ' .' ], ); - }); - - it('adds the slug header to the metadata.', async(): Promise => { - const input = {} as HttpRequest; - input.headers = { 'transfer-encoding': 'chunked', 'content-type': 'text/turtle', slug: 'slugText' }; - const result = (await bodyParser.handle(input))!; - expect(result.metadata.contentType).toEqual('text/turtle'); - expect(result.metadata.get(HTTP.slug)?.value).toEqual('slugText'); - }); - - it('errors if there are multiple slugs.', async(): Promise => { - const input = {} as HttpRequest; - input.headers = { 'transfer-encoding': 'chunked', - 'content-type': 'text/turtle', - slug: [ 'slugTextA', 'slugTextB' ]}; - await expect(bodyParser.handle(input)).rejects.toThrow(UnsupportedHttpError); - }); - - it('adds the link type headers to the metadata.', async(): Promise => { - const input = {} as HttpRequest; - input.headers = { 'transfer-encoding': 'chunked', - 'content-type': 'text/turtle', - link: '; rel="type"' }; - const result = (await bodyParser.handle(input))!; - expect(result.metadata.contentType).toEqual('text/turtle'); - expect(result.metadata.get(RDF.type)?.value).toEqual('http://www.w3.org/ns/ldp#Container'); - }); - - it('ignores unknown link headers.', async(): Promise => { - const input = {} as HttpRequest; - input.headers = { 'transfer-encoding': 'chunked', - 'content-type': 'text/turtle', - link: [ '', 'badLink' ]}; - const result = (await bodyParser.handle(input))!; - expect(result.metadata.quads()).toHaveLength(1); - expect(result.metadata.contentType).toEqual('text/turtle'); + mock.mockRestore(); }); }); diff --git a/test/unit/ldp/http/SparqlUpdateBodyParser.test.ts b/test/unit/ldp/http/SparqlUpdateBodyParser.test.ts index bb1307e185..c41787a44d 100644 --- a/test/unit/ldp/http/SparqlUpdateBodyParser.test.ts +++ b/test/unit/ldp/http/SparqlUpdateBodyParser.test.ts @@ -1,15 +1,19 @@ import { namedNode, quad } from '@rdfjs/data-model'; -import arrayifyStream from 'arrayify-stream'; import { Algebra } from 'sparqlalgebrajs'; import * as algebra from 'sparqlalgebrajs'; import streamifyArray from 'streamify-array'; +import type { MetadataHandler } from '../../../../src/ldp/http/metadata/MetadataHandler'; import { SparqlUpdateBodyParser } from '../../../../src/ldp/http/SparqlUpdateBodyParser'; +import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; import type { HttpRequest } from '../../../../src/server/HttpRequest'; import { UnsupportedHttpError } from '../../../../src/util/errors/UnsupportedHttpError'; import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError'; +import { readableToString } from '../../../../src/util/Util'; +import { StaticAsyncHandler } from '../../../util/StaticAsyncHandler'; describe('A SparqlUpdateBodyParser', (): void => { - const bodyParser = new SparqlUpdateBodyParser(); + const metadataHandler = new StaticAsyncHandler(true, new RepresentationMetadata()) as MetadataHandler; + const bodyParser = new SparqlUpdateBodyParser(metadataHandler); it('only accepts application/sparql-update content.', async(): Promise => { await expect(bodyParser.canHandle({ headers: {}} as HttpRequest)).rejects.toThrow(UnsupportedMediaTypeHttpError); @@ -29,15 +33,17 @@ describe('A SparqlUpdateBodyParser', (): void => { throw 'apple'; }); await expect(bodyParser.handle(streamifyArray( - [ 'DELETE DATA { }' ], + [ 'DELETE DATA { }' ], ) as HttpRequest)).rejects.toThrow(UnsupportedHttpError); mock.mockRestore(); }); it('converts SPARQL updates to algebra.', async(): Promise => { - const result = await bodyParser.handle(streamifyArray( - [ 'DELETE DATA { }' ], - ) as HttpRequest); + const mock = jest.spyOn(metadataHandler, 'handleSafe'); + const input = streamifyArray( + [ 'DELETE DATA { }' ], + ) as HttpRequest; + const result = await bodyParser.handle(input); expect(result.algebra.type).toBe(Algebra.types.DELETE_INSERT); expect((result.algebra as Algebra.DeleteInsert).delete).toBeRdfIsomorphic([ quad( namedNode('http://test.com/s'), @@ -45,11 +51,13 @@ describe('A SparqlUpdateBodyParser', (): void => { namedNode('http://test.com/o'), ) ]); expect(result.binary).toBe(true); - expect(result.metadata.contentType).toEqual('application/sparql-update'); + expect(metadataHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(metadataHandler.handleSafe).toHaveBeenLastCalledWith(input); // Workaround for Node 10 not exposing objectMode - expect((await arrayifyStream(result.data)).join('')).toEqual( - 'DELETE DATA { }', + expect(await readableToString(result.data)).toEqual( + 'DELETE DATA { }', ); + mock.mockRestore(); }); });