From 5e1bb10f81dbc81f6d0700a8c108030e5392b36d Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 4 Aug 2020 10:23:00 +0200 Subject: [PATCH] feat: Convert data from ResourceStore based on preferences --- index.ts | 8 ++- src/storage/PassthroughStore.ts | 42 +++++++++++++ src/storage/PatchingStore.ts | 27 +-------- src/storage/RepresentationConverter.ts | 24 -------- src/storage/RepresentationConvertingStore.ts | 47 +++++++++++++++ src/storage/conversion/ConversionUtil.ts | 47 +++++++++++++++ .../conversion/QuadToTurtleConverter.ts | 28 +++++++++ .../conversion/RepresentationConverter.ts | 24 ++++++++ .../conversion/TurtleToQuadConverter.ts | 38 ++++++++++++ src/util/Util.ts | 29 +++++++++ test/unit/storage/PassthroughStore.test.ts | 51 ++++++++++++++++ test/unit/storage/PatchingStore.test.ts | 31 +--------- .../RepresentationConvertingStore.test.ts | 60 +++++++++++++++++++ .../storage/conversion/ConversionUtil.test.ts | 52 ++++++++++++++++ .../conversion/QuadToTurtleConverter.test.ts | 43 +++++++++++++ .../conversion/TurtleToQuadConverter.test.ts | 59 ++++++++++++++++++ test/unit/util/Util.test.ts | 34 +++++++++++ 17 files changed, 565 insertions(+), 79 deletions(-) create mode 100644 src/storage/PassthroughStore.ts delete mode 100644 src/storage/RepresentationConverter.ts create mode 100644 src/storage/RepresentationConvertingStore.ts create mode 100644 src/storage/conversion/ConversionUtil.ts create mode 100644 src/storage/conversion/QuadToTurtleConverter.ts create mode 100644 src/storage/conversion/RepresentationConverter.ts create mode 100644 src/storage/conversion/TurtleToQuadConverter.ts create mode 100644 test/unit/storage/PassthroughStore.test.ts create mode 100644 test/unit/storage/RepresentationConvertingStore.test.ts create mode 100644 test/unit/storage/conversion/ConversionUtil.test.ts create mode 100644 test/unit/storage/conversion/QuadToTurtleConverter.test.ts create mode 100644 test/unit/storage/conversion/TurtleToQuadConverter.test.ts create mode 100644 test/unit/util/Util.test.ts diff --git a/index.ts b/index.ts index 04cea1ac1..766877812 100644 --- a/index.ts +++ b/index.ts @@ -55,6 +55,11 @@ export * from './src/server/HttpHandler'; export * from './src/server/HttpRequest'; export * from './src/server/HttpResponse'; +// Storage/Conversion +export * from './src/storage/conversion/QuadToTurtleConverter'; +export * from './src/storage/conversion/RepresentationConverter'; +export * from './src/storage/conversion/TurtleToQuadConverter'; + // Storage/Patch export * from './src/storage/patch/PatchHandler'; export * from './src/storage/patch/SimpleSparqlUpdatePatchHandler'; @@ -64,8 +69,9 @@ export * from './src/storage/AtomicResourceStore'; export * from './src/storage/Conditions'; export * from './src/storage/Lock'; export * from './src/storage/LockingResourceStore'; +export * from './src/storage/PassthroughStore'; export * from './src/storage/PatchingStore'; -export * from './src/storage/RepresentationConverter'; +export * from './src/storage/RepresentationConvertingStore'; export * from './src/storage/ResourceLocker'; export * from './src/storage/ResourceMapper'; export * from './src/storage/ResourceStore'; diff --git a/src/storage/PassthroughStore.ts b/src/storage/PassthroughStore.ts new file mode 100644 index 000000000..869b56cce --- /dev/null +++ b/src/storage/PassthroughStore.ts @@ -0,0 +1,42 @@ +import { Conditions } from './Conditions'; +import { Patch } from '../ldp/http/Patch'; +import { Representation } from '../ldp/representation/Representation'; +import { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences'; +import { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; +import { ResourceStore } from './ResourceStore'; + +/** + * Store that calls the corresponding functions of the source Store. + * Can be extended by stores that do not want to override all functions + * by implementing a decorator pattern. + */ +export class PassthroughStore implements ResourceStore { + protected readonly source: ResourceStore; + + public constructor(source: ResourceStore) { + this.source = source; + } + + public async addResource(container: ResourceIdentifier, representation: Representation, + conditions?: Conditions): Promise { + return this.source.addResource(container, representation, conditions); + } + + public async deleteResource(identifier: ResourceIdentifier, conditions?: Conditions): Promise { + return this.source.deleteResource(identifier, conditions); + } + + public async getRepresentation(identifier: ResourceIdentifier, preferences: RepresentationPreferences, + conditions?: Conditions): Promise { + return this.source.getRepresentation(identifier, preferences, conditions); + } + + public async modifyResource(identifier: ResourceIdentifier, patch: Patch, conditions?: Conditions): Promise { + return this.source.modifyResource(identifier, patch, conditions); + } + + public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, + conditions?: Conditions): Promise { + return this.source.setRepresentation(identifier, representation, conditions); + } +} diff --git a/src/storage/PatchingStore.ts b/src/storage/PatchingStore.ts index 5767ff66c..ec61859f6 100644 --- a/src/storage/PatchingStore.ts +++ b/src/storage/PatchingStore.ts @@ -1,8 +1,7 @@ import { Conditions } from './Conditions'; +import { PassthroughStore } from './PassthroughStore'; import { Patch } from '../ldp/http/Patch'; import { PatchHandler } from './patch/PatchHandler'; -import { Representation } from '../ldp/representation/Representation'; -import { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences'; import { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; import { ResourceStore } from './ResourceStore'; @@ -11,34 +10,14 @@ import { ResourceStore } from './ResourceStore'; * If the original store supports the {@link Patch}, behaviour will be identical, * otherwise one of the {@link PatchHandler}s supporting the given Patch will be called instead. */ -export class PatchingStore implements ResourceStore { - private readonly source: ResourceStore; +export class PatchingStore extends PassthroughStore { private readonly patcher: PatchHandler; public constructor(source: ResourceStore, patcher: PatchHandler) { - this.source = source; + super(source); this.patcher = patcher; } - public async addResource(container: ResourceIdentifier, representation: Representation, - conditions?: Conditions): Promise { - return this.source.addResource(container, representation, conditions); - } - - public async deleteResource(identifier: ResourceIdentifier, conditions?: Conditions): Promise { - return this.source.deleteResource(identifier, conditions); - } - - public async getRepresentation(identifier: ResourceIdentifier, preferences: RepresentationPreferences, - conditions?: Conditions): Promise { - return this.source.getRepresentation(identifier, preferences, conditions); - } - - public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, - conditions?: Conditions): Promise { - return this.source.setRepresentation(identifier, representation, conditions); - } - public async modifyResource(identifier: ResourceIdentifier, patch: Patch, conditions?: Conditions): Promise { try { return await this.source.modifyResource(identifier, patch, conditions); diff --git a/src/storage/RepresentationConverter.ts b/src/storage/RepresentationConverter.ts deleted file mode 100644 index 333a01d7b..000000000 --- a/src/storage/RepresentationConverter.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Representation } from '../ldp/representation/Representation'; -import { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences'; - -/** - * Allows converting from one resource representation to another. - */ -export interface RepresentationConverter { - /** - * Checks if the converter supports converting the given resource based on the given preferences. - * @param representation - The input representation. - * @param preferences - The requested representation preferences. - * - * @returns A promise resolving to a boolean representing whether this conversion can be done. - */ - supports: (representation: Representation, preferences: RepresentationPreferences) => Promise; - /** - * Converts the given representation. - * @param representation - The input representation to convert. - * @param preferences - The requested representation preferences. - * - * @returns A promise resolving to the requested representation. - */ - convert: (representation: Representation, preferences: RepresentationPreferences) => Promise; -} diff --git a/src/storage/RepresentationConvertingStore.ts b/src/storage/RepresentationConvertingStore.ts new file mode 100644 index 000000000..e34dfa507 --- /dev/null +++ b/src/storage/RepresentationConvertingStore.ts @@ -0,0 +1,47 @@ +import { Conditions } from './Conditions'; +import { matchingMediaType } from '../util/Util'; +import { PassthroughStore } from './PassthroughStore'; +import { Representation } from '../ldp/representation/Representation'; +import { RepresentationConverter } from './conversion/RepresentationConverter'; +import { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences'; +import { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; +import { ResourceStore } from './ResourceStore'; + +/** + * Store that overrides the `getRepresentation` function. + * Tries to convert the {@link Representation} it got from the source store + * so it matches one of the given type preferences. + * + * In the future this class should take the preferences of the request into account. + * Even if there is a match with the output from the store, + * if there is a low weight for that type conversions might still be preferred. + */ +export class RepresentationConvertingStore extends PassthroughStore { + private readonly converter: RepresentationConverter; + + public constructor(source: ResourceStore, converter: RepresentationConverter) { + super(source); + this.converter = converter; + } + + public async getRepresentation(identifier: ResourceIdentifier, preferences: RepresentationPreferences, + conditions?: Conditions): Promise { + const representation = await super.getRepresentation(identifier, preferences, conditions); + if (this.matchesPreferences(representation, preferences)) { + return representation; + } + return this.converter.handleSafe({ identifier, representation, preferences }); + } + + private matchesPreferences(representation: Representation, preferences: RepresentationPreferences): boolean { + if (!preferences.type) { + return true; + } + return Boolean( + representation.metadata.contentType && + preferences.type.some((type): boolean => + type.weight > 0 && + matchingMediaType(type.value, representation.metadata.contentType!)), + ); + } +} diff --git a/src/storage/conversion/ConversionUtil.ts b/src/storage/conversion/ConversionUtil.ts new file mode 100644 index 000000000..5f18c1b68 --- /dev/null +++ b/src/storage/conversion/ConversionUtil.ts @@ -0,0 +1,47 @@ +import { matchingMediaType } from '../../util/Util'; +import { RepresentationConverterArgs } from './RepresentationConverter'; +import { RepresentationPreference } from '../../ldp/representation/RepresentationPreference'; +import { RepresentationPreferences } from '../../ldp/representation/RepresentationPreferences'; +import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError'; + +/** + * Filters out the media types from the preferred types that correspond to one of the supported types. + * @param preferences - Preferences for output type. + * @param supported - Types supported by the parser. + * + * @throws UnsupportedHttpError + * If the type preferences are undefined. + * + * @returns The filtered list of preferences. + */ +export const matchingTypes = (preferences: RepresentationPreferences, supported: string[]): +RepresentationPreference[] => { + if (!Array.isArray(preferences.type)) { + throw new UnsupportedHttpError('Output type required for conversion.'); + } + return preferences.type.filter(({ value, weight }): boolean => weight > 0 && + supported.some((type): boolean => matchingMediaType(value, type))); +}; + +/** + * Runs some standard checks on the input request: + * - Checks if there is a content type for the input. + * - Checks if the input type is supported by the parser. + * - Checks if the parser can produce one of the preferred output types. + * @param request - Incoming arguments. + * @param supportedIn - Media types that can be parsed by the converter. + * @param supportedOut - Media types that can be produced by the converter. + */ +export const checkRequest = (request: RepresentationConverterArgs, supportedIn: string[], supportedOut: string[]): +void => { + const inType = request.representation.metadata.contentType; + if (!inType) { + throw new UnsupportedHttpError('Input type required for conversion.'); + } + if (!supportedIn.some((type): boolean => matchingMediaType(inType, type))) { + throw new UnsupportedHttpError(`Can only convert from ${supportedIn} to ${supportedOut}.`); + } + if (matchingTypes(request.preferences, supportedOut).length <= 0) { + throw new UnsupportedHttpError(`Can only convert from ${supportedIn} to ${supportedOut}.`); + } +}; diff --git a/src/storage/conversion/QuadToTurtleConverter.ts b/src/storage/conversion/QuadToTurtleConverter.ts new file mode 100644 index 000000000..2e95baea6 --- /dev/null +++ b/src/storage/conversion/QuadToTurtleConverter.ts @@ -0,0 +1,28 @@ +import { checkRequest } from './ConversionUtil'; +import { Representation } from '../../ldp/representation/Representation'; +import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata'; +import { StreamWriter } from 'n3'; +import { CONTENT_TYPE_QUADS, DATA_TYPE_BINARY } from '../../util/ContentTypes'; +import { RepresentationConverter, RepresentationConverterArgs } from './RepresentationConverter'; + +/** + * Converts `internal/quads` to `text/turtle`. + */ +export class QuadToTurtleConverter extends RepresentationConverter { + public async canHandle(input: RepresentationConverterArgs): Promise { + checkRequest(input, [ CONTENT_TYPE_QUADS ], [ 'text/turtle' ]); + } + + public async handle(input: RepresentationConverterArgs): Promise { + return this.quadsToTurtle(input.representation); + } + + private quadsToTurtle(quads: Representation): Representation { + const metadata: RepresentationMetadata = { ...quads.metadata, contentType: 'text/turtle' }; + return { + dataType: DATA_TYPE_BINARY, + data: quads.data.pipe(new StreamWriter({ format: 'text/turtle' })), + metadata, + }; + } +} diff --git a/src/storage/conversion/RepresentationConverter.ts b/src/storage/conversion/RepresentationConverter.ts new file mode 100644 index 000000000..8b7c40307 --- /dev/null +++ b/src/storage/conversion/RepresentationConverter.ts @@ -0,0 +1,24 @@ +import { AsyncHandler } from '../../util/AsyncHandler'; +import { Representation } from '../../ldp/representation/Representation'; +import { RepresentationPreferences } from '../../ldp/representation/RepresentationPreferences'; +import { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; + +export interface RepresentationConverterArgs { + /** + * Identifier of the resource. Can be used as base IRI. + */ + identifier: ResourceIdentifier; + /** + * Representation to convert. + */ + representation: Representation; + /** + * Preferences indicating what is requested. + */ + preferences: RepresentationPreferences; +} + +/** + * Converts a {@link Representation} from one media type to another, based on the given preferences. + */ +export abstract class RepresentationConverter extends AsyncHandler {} diff --git a/src/storage/conversion/TurtleToQuadConverter.ts b/src/storage/conversion/TurtleToQuadConverter.ts new file mode 100644 index 000000000..112d023f2 --- /dev/null +++ b/src/storage/conversion/TurtleToQuadConverter.ts @@ -0,0 +1,38 @@ +import { checkRequest } from './ConversionUtil'; +import { PassThrough } from 'stream'; +import { Representation } from '../../ldp/representation/Representation'; +import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata'; +import { StreamParser } from 'n3'; +import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError'; +import { CONTENT_TYPE_QUADS, DATA_TYPE_QUAD } from '../../util/ContentTypes'; +import { RepresentationConverter, RepresentationConverterArgs } from './RepresentationConverter'; + +/** + * Converts `text/turtle` to `internal/quads`. + */ +export class TurtleToQuadConverter extends RepresentationConverter { + public async canHandle(input: RepresentationConverterArgs): Promise { + checkRequest(input, [ 'text/turtle' ], [ CONTENT_TYPE_QUADS ]); + } + + public async handle(input: RepresentationConverterArgs): Promise { + return this.turtleToQuads(input.representation, input.identifier.path); + } + + private turtleToQuads(turtle: Representation, baseIRI: string): Representation { + const metadata: RepresentationMetadata = { ...turtle.metadata, contentType: CONTENT_TYPE_QUADS }; + + // Catch parsing errors and emit correct error + // Node 10 requires both writableObjectMode and readableObjectMode + const errorStream = new PassThrough({ writableObjectMode: true, readableObjectMode: true }); + const data = turtle.data.pipe(new StreamParser({ format: 'text/turtle', baseIRI })); + data.pipe(errorStream); + data.on('error', (error): boolean => errorStream.emit('error', new UnsupportedHttpError(error.message))); + + return { + dataType: DATA_TYPE_QUAD, + data: errorStream, + metadata, + }; + } +} diff --git a/src/util/Util.ts b/src/util/Util.ts index f8b461013..310f08df6 100644 --- a/src/util/Util.ts +++ b/src/util/Util.ts @@ -12,4 +12,33 @@ import { Readable } from 'stream'; */ export const ensureTrailingSlash = (path: string): string => path.replace(/\/*$/u, '/'); +/** + * Joins all strings of a stream. + * @param stream - Stream of strings. + * + * @returns The joined string. + */ export const readableToString = async(stream: Readable): Promise => (await arrayifyStream(stream)).join(''); + +/** + * Checks if the given two media types/ranges match each other. + * Takes wildcards into account. + * @param mediaA - Media type to match. + * @param mediaB - Media type to match. + * + * @returns True if the media type patterns can match each other. + */ +export const matchingMediaType = (mediaA: string, mediaB: string): boolean => { + const [ typeA, subTypeA ] = mediaA.split('/'); + const [ typeB, subTypeB ] = mediaB.split('/'); + if (typeA === '*' || typeB === '*') { + return true; + } + if (typeA !== typeB) { + return false; + } + if (subTypeA === '*' || subTypeB === '*') { + return true; + } + return subTypeA === subTypeB; +}; diff --git a/test/unit/storage/PassthroughStore.test.ts b/test/unit/storage/PassthroughStore.test.ts new file mode 100644 index 000000000..c43974f6c --- /dev/null +++ b/test/unit/storage/PassthroughStore.test.ts @@ -0,0 +1,51 @@ +import { PassthroughStore } from '../../../src/storage/PassthroughStore'; +import { Patch } from '../../../src/ldp/http/Patch'; +import { Representation } from '../../../src/ldp/representation/Representation'; +import { ResourceStore } from '../../../src/storage/ResourceStore'; + +describe('A PassthroughStore', (): void => { + let store: PassthroughStore; + let source: ResourceStore; + + beforeEach(async(): Promise => { + source = { + getRepresentation: jest.fn(async(): Promise => 'get'), + addResource: jest.fn(async(): Promise => 'add'), + setRepresentation: jest.fn(async(): Promise => 'set'), + deleteResource: jest.fn(async(): Promise => 'delete'), + modifyResource: jest.fn(async(): Promise => 'modify'), + }; + + store = new PassthroughStore(source); + }); + + it('calls getRepresentation directly from the source.', async(): Promise => { + await expect(store.getRepresentation({ path: 'getPath' }, {})).resolves.toBe('get'); + expect(source.getRepresentation).toHaveBeenCalledTimes(1); + expect(source.getRepresentation).toHaveBeenLastCalledWith({ path: 'getPath' }, {}, undefined); + }); + + it('calls addResource directly from the source.', async(): Promise => { + await expect(store.addResource({ path: 'addPath' }, {} as Representation)).resolves.toBe('add'); + expect(source.addResource).toHaveBeenCalledTimes(1); + expect(source.addResource).toHaveBeenLastCalledWith({ path: 'addPath' }, {}, undefined); + }); + + it('calls setRepresentation directly from the source.', async(): Promise => { + await expect(store.setRepresentation({ path: 'setPath' }, {} as Representation)).resolves.toBe('set'); + expect(source.setRepresentation).toHaveBeenCalledTimes(1); + expect(source.setRepresentation).toHaveBeenLastCalledWith({ path: 'setPath' }, {}, undefined); + }); + + it('calls deleteResource directly from the source.', async(): Promise => { + await expect(store.deleteResource({ path: 'deletePath' })).resolves.toBe('delete'); + expect(source.deleteResource).toHaveBeenCalledTimes(1); + expect(source.deleteResource).toHaveBeenLastCalledWith({ path: 'deletePath' }, undefined); + }); + + it('calls modifyResource directly from the source.', async(): Promise => { + await expect(store.modifyResource({ path: 'modifyPath' }, {} as Patch)).resolves.toBe('modify'); + expect(source.modifyResource).toHaveBeenCalledTimes(1); + expect(source.modifyResource).toHaveBeenLastCalledWith({ path: 'modifyPath' }, {}, undefined); + }); +}); diff --git a/test/unit/storage/PatchingStore.test.ts b/test/unit/storage/PatchingStore.test.ts index e68961f51..1e81a5a77 100644 --- a/test/unit/storage/PatchingStore.test.ts +++ b/test/unit/storage/PatchingStore.test.ts @@ -1,7 +1,6 @@ import { Patch } from '../../../src/ldp/http/Patch'; import { PatchHandler } from '../../../src/storage/patch/PatchHandler'; import { PatchingStore } from '../../../src/storage/PatchingStore'; -import { Representation } from '../../../src/ldp/representation/Representation'; import { ResourceStore } from '../../../src/storage/ResourceStore'; describe('A PatchingStore', (): void => { @@ -12,12 +11,8 @@ describe('A PatchingStore', (): void => { beforeEach(async(): Promise => { source = { - getRepresentation: jest.fn(async(): Promise => 'get'), - addResource: jest.fn(async(): Promise => 'add'), - setRepresentation: jest.fn(async(): Promise => 'set'), - deleteResource: jest.fn(async(): Promise => 'delete'), modifyResource: jest.fn(async(): Promise => 'modify'), - }; + } as unknown as ResourceStore; handleSafeFn = jest.fn(async(): Promise => 'patcher'); patcher = { handleSafe: handleSafeFn } as unknown as PatchHandler; @@ -25,30 +20,6 @@ describe('A PatchingStore', (): void => { store = new PatchingStore(source, patcher); }); - it('calls getRepresentation directly from the source.', async(): Promise => { - await expect(store.getRepresentation({ path: 'getPath' }, {})).resolves.toBe('get'); - expect(source.getRepresentation).toHaveBeenCalledTimes(1); - expect(source.getRepresentation).toHaveBeenLastCalledWith({ path: 'getPath' }, {}, undefined); - }); - - it('calls addResource directly from the source.', async(): Promise => { - await expect(store.addResource({ path: 'addPath' }, {} as Representation)).resolves.toBe('add'); - expect(source.addResource).toHaveBeenCalledTimes(1); - expect(source.addResource).toHaveBeenLastCalledWith({ path: 'addPath' }, {}, undefined); - }); - - it('calls setRepresentation directly from the source.', async(): Promise => { - await expect(store.setRepresentation({ path: 'setPath' }, {} as Representation)).resolves.toBe('set'); - expect(source.setRepresentation).toHaveBeenCalledTimes(1); - expect(source.setRepresentation).toHaveBeenLastCalledWith({ path: 'setPath' }, {}, undefined); - }); - - it('calls deleteResource directly from the source.', async(): Promise => { - await expect(store.deleteResource({ path: 'deletePath' })).resolves.toBe('delete'); - expect(source.deleteResource).toHaveBeenCalledTimes(1); - expect(source.deleteResource).toHaveBeenLastCalledWith({ path: 'deletePath' }, undefined); - }); - it('calls modifyResource directly from the source if available.', async(): Promise => { await expect(store.modifyResource({ path: 'modifyPath' }, {} as Patch)).resolves.toBe('modify'); expect(source.modifyResource).toHaveBeenCalledTimes(1); diff --git a/test/unit/storage/RepresentationConvertingStore.test.ts b/test/unit/storage/RepresentationConvertingStore.test.ts new file mode 100644 index 000000000..4d6fd32c7 --- /dev/null +++ b/test/unit/storage/RepresentationConvertingStore.test.ts @@ -0,0 +1,60 @@ +import { RepresentationConverter } from '../../../src/storage/conversion/RepresentationConverter'; +import { RepresentationConvertingStore } from '../../../src/storage/RepresentationConvertingStore'; +import { ResourceStore } from '../../../src/storage/ResourceStore'; + +describe('A RepresentationConvertingStore', (): void => { + let store: RepresentationConvertingStore; + let source: ResourceStore; + let handleSafeFn: jest.Mock, []>; + let converter: RepresentationConverter; + + beforeEach(async(): Promise => { + source = { + getRepresentation: jest.fn(async(): Promise => ({ data: 'data', metadata: { contentType: 'text/turtle' }})), + } as unknown as ResourceStore; + + handleSafeFn = jest.fn(async(): Promise => 'converter'); + converter = { handleSafe: handleSafeFn } as unknown as RepresentationConverter; + + store = new RepresentationConvertingStore(source, converter); + }); + + it('returns the Representation from the source if no changes are required.', async(): Promise => { + await expect(store.getRepresentation({ path: 'path' }, { type: [ + { value: 'text/*', weight: 0 }, { value: 'text/turtle', weight: 1 }, + ]})).resolves.toEqual({ + data: 'data', + metadata: { contentType: 'text/turtle' }, + }); + expect(source.getRepresentation).toHaveBeenCalledTimes(1); + expect(source.getRepresentation).toHaveBeenLastCalledWith( + { path: 'path' }, { type: [{ value: 'text/*', weight: 0 }, { value: 'text/turtle', weight: 1 }]}, undefined, + ); + expect(handleSafeFn).toHaveBeenCalledTimes(0); + }); + + it('returns the Representation from the source if there are no preferences.', async(): Promise => { + await expect(store.getRepresentation({ path: 'path' }, {})).resolves.toEqual({ + data: 'data', + metadata: { contentType: 'text/turtle' }, + }); + expect(source.getRepresentation).toHaveBeenCalledTimes(1); + expect(source.getRepresentation).toHaveBeenLastCalledWith( + { path: 'path' }, {}, undefined, + ); + expect(handleSafeFn).toHaveBeenCalledTimes(0); + }); + + it('calls the converter if another output is preferred.', async(): Promise => { + await expect(store.getRepresentation({ path: 'path' }, { type: [ + { value: 'text/plain', weight: 1 }, { value: 'text/turtle', weight: 0 }, + ]})).resolves.toEqual('converter'); + expect(source.getRepresentation).toHaveBeenCalledTimes(1); + expect(handleSafeFn).toHaveBeenCalledTimes(1); + expect(handleSafeFn).toHaveBeenLastCalledWith({ + identifier: { path: 'path' }, + representation: { data: 'data', metadata: { contentType: 'text/turtle' }}, + preferences: { type: [{ value: 'text/plain', weight: 1 }, { value: 'text/turtle', weight: 0 }]}, + }); + }); +}); diff --git a/test/unit/storage/conversion/ConversionUtil.test.ts b/test/unit/storage/conversion/ConversionUtil.test.ts new file mode 100644 index 000000000..a3f0e5c25 --- /dev/null +++ b/test/unit/storage/conversion/ConversionUtil.test.ts @@ -0,0 +1,52 @@ +import { Representation } from '../../../../src/ldp/representation/Representation'; +import { RepresentationPreferences } from '../../../../src/ldp/representation/RepresentationPreferences'; +import { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; +import { checkRequest, matchingTypes } from '../../../../src/storage/conversion/ConversionUtil'; + +describe('A ConversionUtil', (): void => { + const identifier: ResourceIdentifier = { path: 'path' }; + + describe('#checkRequest', (): void => { + it('requires an input type.', async(): Promise => { + const representation = { metadata: {}} as Representation; + const preferences: RepresentationPreferences = {}; + expect((): any => checkRequest({ identifier, representation, preferences }, [ '*/*' ], [ '*/*' ])) + .toThrow('Input type required for conversion.'); + }); + + it('requires a matching input type.', async(): Promise => { + const representation = { metadata: { contentType: 'a/x' }} as Representation; + const preferences: RepresentationPreferences = { type: [{ value: 'b/x', weight: 1 }]}; + expect((): any => checkRequest({ identifier, representation, preferences }, [ 'c/x' ], [ '*/*' ])) + .toThrow('Can only convert from c/x to */*.'); + }); + + it('requires a matching output type.', async(): Promise => { + const representation = { metadata: { contentType: 'a/x' }} as Representation; + const preferences: RepresentationPreferences = { type: [{ value: 'b/x', weight: 1 }]}; + expect((): any => checkRequest({ identifier, representation, preferences }, [ '*/*' ], [ 'c/x' ])) + .toThrow('Can only convert from */* to c/x.'); + }); + + it('succeeds with a valid input and output type.', async(): Promise => { + const representation = { metadata: { contentType: 'a/x' }} as Representation; + const preferences: RepresentationPreferences = { type: [{ value: 'b/x', weight: 1 }]}; + expect(checkRequest({ identifier, representation, preferences }, [ '*/*' ], [ '*/*' ])) + .toBeUndefined(); + }); + }); + + describe('#matchingTypes', (): void => { + it('requires type preferences.', async(): Promise => { + const preferences: RepresentationPreferences = {}; + expect((): any => matchingTypes(preferences, [ '*/*' ])) + .toThrow('Output type required for conversion.'); + }); + + it('returns matching types if weight > 0.', async(): Promise => { + const preferences: RepresentationPreferences = { type: + [{ value: 'a/x', weight: 1 }, { value: 'b/x', weight: 0.5 }, { value: 'c/x', weight: 0 }]}; + expect(matchingTypes(preferences, [ 'b/x', 'c/x' ])).toEqual([{ value: 'b/x', weight: 0.5 }]); + }); + }); +}); diff --git a/test/unit/storage/conversion/QuadToTurtleConverter.test.ts b/test/unit/storage/conversion/QuadToTurtleConverter.test.ts new file mode 100644 index 000000000..7f2677a92 --- /dev/null +++ b/test/unit/storage/conversion/QuadToTurtleConverter.test.ts @@ -0,0 +1,43 @@ +import arrayifyStream from 'arrayify-stream'; +import { QuadToTurtleConverter } from '../../../../src/storage/conversion/QuadToTurtleConverter'; +import { Readable } from 'stream'; +import { Representation } from '../../../../src/ldp/representation/Representation'; +import { RepresentationPreferences } from '../../../../src/ldp/representation/RepresentationPreferences'; +import { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; +import streamifyArray from 'streamify-array'; +import { CONTENT_TYPE_QUADS, DATA_TYPE_BINARY } from '../../../../src/util/ContentTypes'; +import { namedNode, triple } from '@rdfjs/data-model'; + +describe('A QuadToTurtleConverter', (): void => { + const converter = new QuadToTurtleConverter(); + const identifier: ResourceIdentifier = { path: 'path' }; + + it('can handle quad to turtle conversions.', async(): Promise => { + const representation = { metadata: { contentType: CONTENT_TYPE_QUADS }} as Representation; + const preferences: RepresentationPreferences = { type: [{ value: 'text/turtle', weight: 1 }]}; + await expect(converter.canHandle({ identifier, representation, preferences })).resolves.toBeUndefined(); + }); + + it('converts quads to turtle.', async(): Promise => { + const representation = { + data: streamifyArray([ triple( + namedNode('http://test.com/s'), + namedNode('http://test.com/p'), + namedNode('http://test.com/o'), + ) ]), + metadata: { contentType: CONTENT_TYPE_QUADS }, + } as Representation; + const preferences: RepresentationPreferences = { type: [{ value: 'text/turtle', weight: 1 }]}; + const result = await converter.handle({ identifier, representation, preferences }); + expect(result).toEqual({ + data: expect.any(Readable), + dataType: DATA_TYPE_BINARY, + metadata: { + contentType: 'text/turtle', + }, + }); + await expect(arrayifyStream(result.data)).resolves.toContain( + ' ', + ); + }); +}); diff --git a/test/unit/storage/conversion/TurtleToQuadConverter.test.ts b/test/unit/storage/conversion/TurtleToQuadConverter.test.ts new file mode 100644 index 000000000..af92ed449 --- /dev/null +++ b/test/unit/storage/conversion/TurtleToQuadConverter.test.ts @@ -0,0 +1,59 @@ +import arrayifyStream from 'arrayify-stream'; +import { Readable } from 'stream'; +import { Representation } from '../../../../src/ldp/representation/Representation'; +import { RepresentationPreferences } from '../../../../src/ldp/representation/RepresentationPreferences'; +import { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; +import streamifyArray from 'streamify-array'; +import { TurtleToQuadConverter } from '../../../../src/storage/conversion/TurtleToQuadConverter'; +import { UnsupportedHttpError } from '../../../../src/util/errors/UnsupportedHttpError'; +import { CONTENT_TYPE_QUADS, DATA_TYPE_QUAD } from '../../../../src/util/ContentTypes'; +import { namedNode, triple } from '@rdfjs/data-model'; + +describe('A TurtleToQuadConverter', (): void => { + const converter = new TurtleToQuadConverter(); + const identifier: ResourceIdentifier = { path: 'path' }; + + it('can handle turtle to quad conversions.', async(): Promise => { + const representation = { metadata: { contentType: 'text/turtle' }} as Representation; + const preferences: RepresentationPreferences = { type: [{ value: CONTENT_TYPE_QUADS, weight: 1 }]}; + await expect(converter.canHandle({ identifier, representation, preferences })).resolves.toBeUndefined(); + }); + + it('converts turtle to quads.', async(): Promise => { + const representation = { + data: streamifyArray([ ' .' ]), + metadata: { contentType: 'text/turtle' }, + } as Representation; + const preferences: RepresentationPreferences = { type: [{ value: CONTENT_TYPE_QUADS, weight: 1 }]}; + const result = await converter.handle({ identifier, representation, preferences }); + expect(result).toEqual({ + data: expect.any(Readable), + dataType: DATA_TYPE_QUAD, + metadata: { + contentType: CONTENT_TYPE_QUADS, + }, + }); + await expect(arrayifyStream(result.data)).resolves.toEqualRdfQuadArray([ triple( + namedNode('http://test.com/s'), + namedNode('http://test.com/p'), + namedNode('http://test.com/o'), + ) ]); + }); + + it('throws an UnsupportedHttpError on invalid triple data.', async(): Promise => { + const representation = { + data: streamifyArray([ ' { + describe('ensureTrailingSlash', (): void => { + it('makes sure there is always exactly 1 slash.', async(): Promise => { + expect(ensureTrailingSlash('http://test.com')).toEqual('http://test.com/'); + expect(ensureTrailingSlash('http://test.com/')).toEqual('http://test.com/'); + expect(ensureTrailingSlash('http://test.com//')).toEqual('http://test.com/'); + expect(ensureTrailingSlash('http://test.com///')).toEqual('http://test.com/'); + }); + }); + + describe('readableToString', (): void => { + it('concatenates all elements of a Readable.', async(): Promise => { + const stream = streamifyArray([ 'a', 'b', 'c' ]); + await expect(readableToString(stream)).resolves.toEqual('abc'); + }); + }); + + describe('matchingMediaType', (): void => { + it('matches all possible media types.', async(): Promise => { + expect(matchingMediaType('*/*', 'text/turtle')).toBeTruthy(); + expect(matchingMediaType('text/*', '*/*')).toBeTruthy(); + expect(matchingMediaType('text/*', 'text/turtle')).toBeTruthy(); + expect(matchingMediaType('text/plain', 'text/*')).toBeTruthy(); + expect(matchingMediaType('text/turtle', 'text/turtle')).toBeTruthy(); + + expect(matchingMediaType('text/*', 'application/*')).toBeFalsy(); + expect(matchingMediaType('text/plain', 'application/*')).toBeFalsy(); + expect(matchingMediaType('text/plain', 'text/turtle')).toBeFalsy(); + }); + }); +});