diff --git a/src/storage/conversion/ChainedConverter.ts b/src/storage/conversion/ChainedConverter.ts new file mode 100644 index 000000000..c51e8dabb --- /dev/null +++ b/src/storage/conversion/ChainedConverter.ts @@ -0,0 +1,53 @@ +import { Representation } from '../../ldp/representation/Representation'; +import { RepresentationPreferences } from '../../ldp/representation/RepresentationPreferences'; +import { RepresentationConverter, RepresentationConverterArgs } from './RepresentationConverter'; + +/** + * 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. + */ +export class ChainedConverter extends RepresentationConverter { + private readonly converters: RepresentationConverter[]; + private readonly chainTypes: string[]; + + /** + * 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`. + * @param converters - The chain of converters. + * @param chainTypes - The intermediate types of the chain. + */ + public constructor(converters: RepresentationConverter[], chainTypes: string[]) { + 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; + } + + public async canHandle(input: RepresentationConverterArgs): Promise { + // 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 }); + + // Check if the last converter can produce the output + 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 }); + } + + 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 }]}; + args.representation = await this.converters[i].handle(args); + } + return this.converters.slice(-1)[0].handle(args); + } +} diff --git a/test/unit/storage/conversion/ChainedConverter.test.ts b/test/unit/storage/conversion/ChainedConverter.test.ts new file mode 100644 index 000000000..5da18984e --- /dev/null +++ b/test/unit/storage/conversion/ChainedConverter.test.ts @@ -0,0 +1,85 @@ +import { Representation } from '../../../../src/ldp/representation/Representation'; +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'; + +class DummyConverter extends RepresentationConverter { + private readonly inType: string; + private readonly outType: string; + + public constructor(inType: string, outType: string) { + super(); + this.inType = inType; + this.outType = outType; + } + + public async canHandle(input: RepresentationConverterArgs): Promise { + checkRequest(input, [ this.inType ], [ this.outType ]); + } + + public async handle(input: RepresentationConverterArgs): Promise { + const representation: Representation = { ...input.representation }; + representation.metadata = { ...input.representation.metadata, contentType: this.outType }; + return representation; + } +} + +describe('A ChainedConverter', (): void => { + let converters: RepresentationConverter[]; + let converter: ChainedConverter; + let representation: Representation; + let preferences: RepresentationPreferences; + let args: RepresentationConverterArgs; + + beforeEach(async(): Promise => { + converters = [ + new DummyConverter('text/turtle', 'chain/1'), + new DummyConverter('chain/1', 'chain/2'), + new DummyConverter('chain/2', 'internal/quads'), + ]; + converter = new ChainedConverter(converters, [ 'chain/1', 'chain/2' ]); + + 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' ])) + .toBeInstanceOf(ChainedConverter); + }); + + it('can handle requests with the correct in- and output.', async(): Promise => { + await expect(converter.canHandle(args)).resolves.toBeUndefined(); + }); + + it('errors if the start of the chain does not support the representation type.', async(): Promise => { + representation.metadata.contentType = 'bad/type'; + await expect(converter.canHandle(args)).rejects.toThrow(); + }); + + it('errors if the end of the chain does not support the preferences.', async(): Promise => { + delete preferences.type; + await expect(converter.canHandle(args)).rejects.toThrow(); + }); + + it('runs the data through the chain.', async(): Promise => { + jest.spyOn(converters[0], 'handle'); + jest.spyOn(converters[1], 'handle'); + jest.spyOn(converters[2], 'handle'); + + const result = await converter.handle(args); + expect(result.metadata.contentType).toEqual('internal/quads'); + expect((converters[0] as any).handle).toHaveBeenCalledTimes(1); + expect((converters[1] as any).handle).toHaveBeenCalledTimes(1); + expect((converters[2] as any).handle).toHaveBeenCalledTimes(1); + }); +});