diff --git a/src/storage/conversion/ChainedConverter.ts b/src/storage/conversion/ChainedConverter.ts index c51e8dabb..f6683fd85 100644 --- a/src/storage/conversion/ChainedConverter.ts +++ b/src/storage/conversion/ChainedConverter.ts @@ -1,53 +1,104 @@ import { Representation } from '../../ldp/representation/Representation'; import { RepresentationPreferences } from '../../ldp/representation/RepresentationPreferences'; -import { RepresentationConverter, RepresentationConverterArgs } from './RepresentationConverter'; +import { matchingMediaType } from '../../util/Util'; +import { RepresentationConverterArgs } from './RepresentationConverter'; +import { TypedRepresentationConverter } from './TypedRepresentationConverter'; /** * A meta converter that takes an array of other converters as input. - * It chains these converters based on given intermediate types that are supported by converters on either side. + * It chains these converters by finding intermediate types that are supported by converters on either side. */ -export class ChainedConverter extends RepresentationConverter { - private readonly converters: RepresentationConverter[]; - private readonly chainTypes: string[]; +export class ChainedConverter extends TypedRepresentationConverter { + private readonly converters: TypedRepresentationConverter[]; /** * Creates the chain of converters based on the input. - * The list of `converters` needs to be at least 2 long, - * and `chainTypes` needs to be the same length - 1, - * as each type at index `i` corresponds to the output type of converter `i` - * and input type of converter `i+1`. + * The list of `converters` needs to be at least 2 long. * @param converters - The chain of converters. - * @param chainTypes - The intermediate types of the chain. */ - public constructor(converters: RepresentationConverter[], chainTypes: string[]) { + public constructor(converters: TypedRepresentationConverter[]) { super(); if (converters.length < 2) { throw new Error('At least 2 converters are required.'); } - if (chainTypes.length !== converters.length - 1) { - throw new Error('1 type is required per converter chain.'); - } - this.converters = converters; - this.chainTypes = chainTypes; + this.converters = [ ...converters ]; + } + + protected get first(): TypedRepresentationConverter { + return this.converters[0]; + } + + protected get last(): TypedRepresentationConverter { + return this.converters[this.converters.length - 1]; + } + + public async getInputTypes(): Promise<{ [contentType: string]: number }> { + return this.first.getInputTypes(); + } + + public async getOutputTypes(): Promise<{ [contentType: string]: number }> { + return this.last.getOutputTypes(); } public async canHandle(input: RepresentationConverterArgs): Promise { + // We assume a chain can be constructed, otherwise there would be a configuration issue // Check if the first converter can handle the input - const preferences: RepresentationPreferences = { type: [{ value: this.chainTypes[0], weight: 1 }]}; - await this.converters[0].canHandle({ ...input, preferences }); + const firstChain = await this.getMatchingType(this.converters[0], this.converters[1]); + const preferences: RepresentationPreferences = { type: [{ value: firstChain, weight: 1 }]}; + await this.first.canHandle({ ...input, preferences }); // Check if the last converter can produce the output + const idx = this.converters.length - 1; + const lastChain = await this.getMatchingType(this.converters[idx - 1], this.converters[idx]); const representation: Representation = { ...input.representation }; - representation.metadata = { ...input.representation.metadata, contentType: this.chainTypes.slice(-1)[0] }; - await this.converters.slice(-1)[0].canHandle({ ...input, representation }); + representation.metadata = { ...input.representation.metadata, contentType: lastChain }; + await this.last.canHandle({ ...input, representation }); } public async handle(input: RepresentationConverterArgs): Promise { const args = { ...input }; - for (let i = 0; i < this.chainTypes.length; ++i) { - args.preferences = { type: [{ value: this.chainTypes[i], weight: 1 }]}; + for (let i = 0; i < this.converters.length - 1; ++i) { + const value = await this.getMatchingType(this.converters[i], this.converters[i + 1]); + args.preferences = { type: [{ value, weight: 1 }]}; args.representation = await this.converters[i].handle(args); } - return this.converters.slice(-1)[0].handle(args); + args.preferences = input.preferences; + return this.last.handle(args); + } + + /** + * Finds the best media type that can be used to chain 2 converters. + */ + protected async getMatchingType(left: TypedRepresentationConverter, right: TypedRepresentationConverter): + Promise { + const leftTypes = await left.getOutputTypes(); + const rightTypes = await right.getInputTypes(); + let bestMatch: { type: string; weight: number } = { type: 'invalid', weight: 0 }; + + // Try to find the matching type with the best weight + const leftKeys = Object.keys(leftTypes); + const rightKeys = Object.keys(rightTypes); + for (const leftType of leftKeys) { + const leftWeight = leftTypes[leftType]; + if (leftWeight <= bestMatch.weight) { + continue; + } + for (const rightType of rightKeys) { + const rightWeight = rightTypes[rightType]; + const weight = leftWeight * rightWeight; + if (weight > bestMatch.weight && matchingMediaType(leftType, rightType)) { + bestMatch = { type: leftType, weight }; + if (weight === 1) { + return bestMatch.type; + } + } + } + } + + if (bestMatch.weight === 0) { + throw new Error(`No match found between ${leftKeys} and ${rightKeys}`); + } + + return bestMatch.type; } } diff --git a/src/storage/conversion/QuadToRdfConverter.ts b/src/storage/conversion/QuadToRdfConverter.ts index 7ac5eb238..bf53442d3 100644 --- a/src/storage/conversion/QuadToRdfConverter.ts +++ b/src/storage/conversion/QuadToRdfConverter.ts @@ -5,12 +5,21 @@ import { RepresentationMetadata } from '../../ldp/representation/RepresentationM import { RepresentationPreferences } from '../../ldp/representation/RepresentationPreferences'; import { CONTENT_TYPE_QUADS, DATA_TYPE_BINARY } from '../../util/ContentTypes'; import { checkRequest, matchingTypes } from './ConversionUtil'; -import { RepresentationConverter, RepresentationConverterArgs } from './RepresentationConverter'; +import { RepresentationConverterArgs } from './RepresentationConverter'; +import { TypedRepresentationConverter } from './TypedRepresentationConverter'; /** * Converts `internal/quads` to most major RDF serializations. */ -export class QuadToRdfConverter extends RepresentationConverter { +export class QuadToRdfConverter extends TypedRepresentationConverter { + public async getInputTypes(): Promise<{ [contentType: string]: number }> { + return { [CONTENT_TYPE_QUADS]: 1 }; + } + + public async getOutputTypes(): Promise<{ [contentType: string]: number }> { + return rdfSerializer.getContentTypesPrioritized(); + } + public async canHandle(input: RepresentationConverterArgs): Promise { checkRequest(input, [ CONTENT_TYPE_QUADS ], await rdfSerializer.getContentTypes()); } diff --git a/src/storage/conversion/RdfToQuadConverter.ts b/src/storage/conversion/RdfToQuadConverter.ts index 73484691b..2c8542efa 100644 --- a/src/storage/conversion/RdfToQuadConverter.ts +++ b/src/storage/conversion/RdfToQuadConverter.ts @@ -5,12 +5,21 @@ import { RepresentationMetadata } from '../../ldp/representation/RepresentationM import { CONTENT_TYPE_QUADS, DATA_TYPE_QUAD } from '../../util/ContentTypes'; import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError'; import { checkRequest } from './ConversionUtil'; -import { RepresentationConverter, RepresentationConverterArgs } from './RepresentationConverter'; +import { RepresentationConverterArgs } from './RepresentationConverter'; +import { TypedRepresentationConverter } from './TypedRepresentationConverter'; /** * Converts most major RDF serializations to `internal/quads`. */ -export class RdfToQuadConverter extends RepresentationConverter { +export class RdfToQuadConverter extends TypedRepresentationConverter { + public async getInputTypes(): Promise<{ [contentType: string]: number }> { + return rdfParser.getContentTypesPrioritized(); + } + + public async getOutputTypes(): Promise<{ [contentType: string]: number }> { + return { [CONTENT_TYPE_QUADS]: 1 }; + } + public async canHandle(input: RepresentationConverterArgs): Promise { checkRequest(input, await rdfParser.getContentTypes(), [ CONTENT_TYPE_QUADS ]); } diff --git a/src/storage/conversion/TypedRepresentationConverter.ts b/src/storage/conversion/TypedRepresentationConverter.ts new file mode 100644 index 000000000..6a4bf8c56 --- /dev/null +++ b/src/storage/conversion/TypedRepresentationConverter.ts @@ -0,0 +1,20 @@ +import { RepresentationConverter } from './RepresentationConverter'; + +/** + * A {@link RepresentationConverter} that allows requesting the supported types. + */ +export abstract class TypedRepresentationConverter extends RepresentationConverter { + /** + * Get a hash of all supported input content types for this converter, mapped to a numerical priority. + * The priority weight goes from 0 up to 1. + * @returns A promise resolving to a hash mapping content type to a priority number. + */ + public abstract getInputTypes(): Promise<{ [contentType: string]: number }>; + + /** + * Get a hash of all supported output content types for this converter, mapped to a numerical priority. + * The priority weight goes from 0 up to 1. + * @returns A promise resolving to a hash mapping content type to a priority number. + */ + public abstract getOutputTypes(): Promise<{ [contentType: string]: number }>; +} diff --git a/test/unit/storage/conversion/ChainedConverter.test.ts b/test/unit/storage/conversion/ChainedConverter.test.ts index 5da18984e..d010a0c6b 100644 --- a/test/unit/storage/conversion/ChainedConverter.test.ts +++ b/test/unit/storage/conversion/ChainedConverter.test.ts @@ -2,34 +2,40 @@ import { Representation } from '../../../../src/ldp/representation/Representatio import { RepresentationPreferences } from '../../../../src/ldp/representation/RepresentationPreferences'; import { ChainedConverter } from '../../../../src/storage/conversion/ChainedConverter'; import { checkRequest } from '../../../../src/storage/conversion/ConversionUtil'; -import { - RepresentationConverter, - RepresentationConverterArgs, -} from '../../../../src/storage/conversion/RepresentationConverter'; +import { RepresentationConverterArgs } from '../../../../src/storage/conversion/RepresentationConverter'; +import { TypedRepresentationConverter } from '../../../../src/storage/conversion/TypedRepresentationConverter'; -class DummyConverter extends RepresentationConverter { - private readonly inType: string; - private readonly outType: string; +class DummyConverter extends TypedRepresentationConverter { + private readonly inTypes: { [contentType: string]: number }; + private readonly outTypes: { [contentType: string]: number }; - public constructor(inType: string, outType: string) { + public constructor(inTypes: { [contentType: string]: number }, outTypes: { [contentType: string]: number }) { super(); - this.inType = inType; - this.outType = outType; + this.inTypes = inTypes; + this.outTypes = outTypes; + } + + public async getInputTypes(): Promise<{ [contentType: string]: number }> { + return this.inTypes; + } + + public async getOutputTypes(): Promise<{ [contentType: string]: number }> { + return this.outTypes; } public async canHandle(input: RepresentationConverterArgs): Promise { - checkRequest(input, [ this.inType ], [ this.outType ]); + checkRequest(input, Object.keys(this.inTypes), Object.keys(this.outTypes)); } public async handle(input: RepresentationConverterArgs): Promise { const representation: Representation = { ...input.representation }; - representation.metadata = { ...input.representation.metadata, contentType: this.outType }; + representation.metadata = { ...input.representation.metadata, contentType: input.preferences.type![0].value }; return representation; } } describe('A ChainedConverter', (): void => { - let converters: RepresentationConverter[]; + let converters: TypedRepresentationConverter[]; let converter: ChainedConverter; let representation: Representation; let preferences: RepresentationPreferences; @@ -37,26 +43,32 @@ describe('A ChainedConverter', (): void => { beforeEach(async(): Promise => { converters = [ - new DummyConverter('text/turtle', 'chain/1'), - new DummyConverter('chain/1', 'chain/2'), - new DummyConverter('chain/2', 'internal/quads'), + new DummyConverter({ 'text/turtle': 1 }, { 'chain/1': 0.9, 'chain/x': 0.5 }), + new DummyConverter({ 'chain/*': 1, 'chain/x': 0.5 }, { 'chain/2': 1 }), + new DummyConverter({ 'chain/2': 1 }, { 'internal/quads': 1 }), ]; - converter = new ChainedConverter(converters, [ 'chain/1', 'chain/2' ]); + converter = new ChainedConverter(converters); representation = { metadata: { contentType: 'text/turtle' } as any } as Representation; preferences = { type: [{ value: 'internal/quads', weight: 1 }]}; args = { representation, preferences, identifier: { path: 'path' }}; }); - it('needs at least 2 converter and n-1 chains.', async(): Promise => { - expect((): any => new ChainedConverter([], [])).toThrow('At least 2 converters are required.'); - expect((): any => new ChainedConverter([ converters[0] ], [])).toThrow('At least 2 converters are required.'); - expect((): any => new ChainedConverter([ converters[0], converters[1] ], [])) - .toThrow('1 type is required per converter chain.'); - expect(new ChainedConverter([ converters[0], converters[1] ], [ 'apple' ])) + it('needs at least 2 converters.', async(): Promise => { + expect((): any => new ChainedConverter([])).toThrow('At least 2 converters are required.'); + expect((): any => new ChainedConverter([ converters[0] ])).toThrow('At least 2 converters are required.'); + expect(new ChainedConverter([ converters[0], converters[1] ])) .toBeInstanceOf(ChainedConverter); }); + it('supports the same inputs as the first converter of the chain.', async(): Promise => { + await expect(converter.getInputTypes()).resolves.toEqual(await converters[0].getInputTypes()); + }); + + it('supports the same outputs as the last converter of the chain.', async(): Promise => { + await expect(converter.getOutputTypes()).resolves.toEqual(await converters[2].getOutputTypes()); + }); + it('can handle requests with the correct in- and output.', async(): Promise => { await expect(converter.canHandle(args)).resolves.toBeUndefined(); }); @@ -82,4 +94,13 @@ describe('A ChainedConverter', (): void => { expect((converters[1] as any).handle).toHaveBeenCalledTimes(1); expect((converters[2] as any).handle).toHaveBeenCalledTimes(1); }); + + it('errors if there is no valid chain at runtime.', async(): Promise => { + converters = [ + new DummyConverter({ 'text/turtle': 1 }, { 'chain/1': 0.9, 'chain/x': 0.5 }), + new DummyConverter({ 'chain/2': 1 }, { 'internal/quads': 1 }), + ]; + converter = new ChainedConverter(converters); + await expect(converter.handle(args)).rejects.toThrow(); + }); }); diff --git a/test/unit/storage/conversion/QuadToRdfConverter.test.ts b/test/unit/storage/conversion/QuadToRdfConverter.test.ts index 324d2d0f2..2d44f9bc6 100644 --- a/test/unit/storage/conversion/QuadToRdfConverter.test.ts +++ b/test/unit/storage/conversion/QuadToRdfConverter.test.ts @@ -1,4 +1,5 @@ import { namedNode, triple } from '@rdfjs/data-model'; +import rdfSerializer from 'rdf-serialize'; import stringifyStream from 'stream-to-string'; import streamifyArray from 'streamify-array'; import { Representation } from '../../../../src/ldp/representation/Representation'; @@ -11,6 +12,14 @@ describe('A QuadToRdfConverter', (): void => { const converter = new QuadToRdfConverter(); const identifier: ResourceIdentifier = { path: 'path' }; + it('supports parsing quads.', async(): Promise => { + await expect(converter.getInputTypes()).resolves.toEqual({ [CONTENT_TYPE_QUADS]: 1 }); + }); + + it('supports serializing as the same types as rdfSerializer.', async(): Promise => { + await expect(converter.getOutputTypes()).resolves.toEqual(await rdfSerializer.getContentTypesPrioritized()); + }); + 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 }]}; diff --git a/test/unit/storage/conversion/RdfToQuadConverter.test.ts b/test/unit/storage/conversion/RdfToQuadConverter.test.ts index 10a0f8420..18ae8ed39 100644 --- a/test/unit/storage/conversion/RdfToQuadConverter.test.ts +++ b/test/unit/storage/conversion/RdfToQuadConverter.test.ts @@ -1,6 +1,7 @@ import { Readable } from 'stream'; import { namedNode, triple } from '@rdfjs/data-model'; import arrayifyStream from 'arrayify-stream'; +import rdfParser from 'rdf-parse'; import streamifyArray from 'streamify-array'; import { Representation } from '../../../../src/ldp/representation/Representation'; import { RepresentationPreferences } from '../../../../src/ldp/representation/RepresentationPreferences'; @@ -13,6 +14,14 @@ describe('A RdfToQuadConverter.test.ts', (): void => { const converter = new RdfToQuadConverter(); const identifier: ResourceIdentifier = { path: 'path' }; + it('supports parsing the same types as rdfParser.', async(): Promise => { + await expect(converter.getInputTypes()).resolves.toEqual(await rdfParser.getContentTypesPrioritized()); + }); + + it('supports serializing as quads.', async(): Promise => { + await expect(converter.getOutputTypes()).resolves.toEqual({ [CONTENT_TYPE_QUADS]: 1 }); + }); + 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 }]};