feat: Determine Typed Converter output based on input type

This commit is contained in:
Joachim Van Herwegen 2021-10-26 16:30:10 +02:00
parent 27306d6e3f
commit fa94c7d4bb
17 changed files with 107 additions and 134 deletions

View File

@ -1,8 +1,8 @@
import type { ValuePreferences } from '../../http/representation/RepresentationPreferences';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { getConversionTarget, getTypeWeight } from './ConversionUtil';
import { RepresentationConverter } from './RepresentationConverter';
import { getConversionTarget, getTypeWeight, preferencesToString } from './ConversionUtil';
import type { RepresentationConverterArgs } from './RepresentationConverter';
import { TypedRepresentationConverter } from './TypedRepresentationConverter';
type PromiseOrValue<T> = T | Promise<T>;
type ValuePreferencesArg =
@ -22,30 +22,37 @@ async function toValuePreferences(arg: ValuePreferencesArg): Promise<ValuePrefer
}
/**
* A {@link RepresentationConverter} that allows requesting the supported types.
* A base {@link TypedRepresentationConverter} implementation for converters
* that can convert from all its input types to all its output types.
*
* This base class handles the `canHandle` call by comparing the input content type to the stored input types
* and the output preferences to the stored output types.
*
* Output weights are determined by multiplying all stored output weights with the weight of the input type.
*/
export abstract class BaseTypedRepresentationConverter extends RepresentationConverter {
export abstract class BaseTypedRepresentationConverter extends TypedRepresentationConverter {
protected inputTypes: Promise<ValuePreferences>;
protected outputTypes: Promise<ValuePreferences>;
public constructor(inputTypes: ValuePreferencesArg = {}, outputTypes: ValuePreferencesArg = {}) {
public constructor(inputTypes: ValuePreferencesArg, outputTypes: ValuePreferencesArg) {
super();
this.inputTypes = toValuePreferences(inputTypes);
this.outputTypes = toValuePreferences(outputTypes);
}
/**
* Gets the supported input content types for this converter, mapped to a numerical priority.
* Matches all inputs to all outputs.
*/
public async getInputTypes(): Promise<ValuePreferences> {
return this.inputTypes;
}
/**
* Gets the supported output content types for this converter, mapped to a numerical quality.
*/
public async getOutputTypes(): Promise<ValuePreferences> {
return this.outputTypes;
public async getOutputTypes(contentType: string): Promise<ValuePreferences> {
const weight = getTypeWeight(contentType, await this.inputTypes);
if (weight > 0) {
const outputTypes = { ...await this.outputTypes };
for (const [ key, value ] of Object.entries(outputTypes)) {
outputTypes[key] = value * weight;
}
return outputTypes;
}
return {};
}
/**
@ -57,19 +64,18 @@ export abstract class BaseTypedRepresentationConverter extends RepresentationCon
* Throws an error with details if conversion is not possible.
*/
public async canHandle(args: RepresentationConverterArgs): Promise<void> {
const types = [ this.getInputTypes(), this.getOutputTypes() ];
const { contentType } = args.representation.metadata;
if (!contentType) {
throw new NotImplementedHttpError('Can not convert data without a Content-Type.');
}
const [ inputTypes, outputTypes ] = await Promise.all(types);
const outputTypes = await this.getOutputTypes(contentType);
const outputPreferences = args.preferences.type ?? {};
if (getTypeWeight(contentType, inputTypes) === 0 || !getConversionTarget(outputTypes, outputPreferences)) {
if (!getConversionTarget(outputTypes, outputPreferences)) {
throw new NotImplementedHttpError(
`Cannot convert from ${contentType} to ${Object.keys(outputPreferences)
}, only from ${Object.keys(inputTypes)} to ${Object.keys(outputTypes)}.`,
`Cannot convert from ${contentType} to ${preferencesToString(outputPreferences)
}, only to ${preferencesToString(outputTypes)}.`,
);
}
}

View File

@ -3,12 +3,16 @@ import type { ValuePreference, ValuePreferences } from '../../http/representatio
import { getLoggerFor } from '../../logging/LogUtil';
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { cleanPreferences, getBestPreference, getTypeWeight } from './ConversionUtil';
import { cleanPreferences, getBestPreference, getTypeWeight, preferencesToString } from './ConversionUtil';
import type { RepresentationConverterArgs } from './RepresentationConverter';
import { RepresentationConverter } from './RepresentationConverter';
import type { TypedRepresentationConverter } from './TypedRepresentationConverter';
type ConverterPreference = ValuePreference & { converter: TypedRepresentationConverter };
type ConverterPreference = {
converter: TypedRepresentationConverter;
inType: string;
outTypes: ValuePreferences;
};
/**
* A chain of converters that can go from `inTypes` to `outTypes`.
@ -17,16 +21,15 @@ type ConverterPreference = ValuePreference & { converter: TypedRepresentationCon
type ConversionPath = {
converters: TypedRepresentationConverter[];
intermediateTypes: string[];
inTypes: ValuePreferences;
inType: string;
outTypes: ValuePreferences;
};
/**
* The result of applying a `ConversionPath` to a specific input.
* The result of choosing a specific output for a `ConversionPath`.
*/
type MatchedPath = {
path: ConversionPath;
inType: string;
outType: string;
weight: number;
};
@ -78,8 +81,8 @@ export class ChainedConverter extends RepresentationConverter {
return input.representation;
}
const { path, inType, outType } = match;
this.logger.debug(`Converting ${inType} -> ${[ ...path.intermediateTypes, outType ].join(' -> ')}.`);
const { path, outType } = match;
this.logger.debug(`Converting ${path.inType} -> ${[ ...path.intermediateTypes, outType ].join(' -> ')}.`);
const args = { ...input };
for (let i = 0; i < path.converters.length - 1; ++i) {
@ -105,7 +108,7 @@ export class ChainedConverter extends RepresentationConverter {
const weight = getTypeWeight(type, preferences);
if (weight > 0) {
this.logger.debug(`No conversion required: ${type} already matches ${Object.keys(preferences)}`);
this.logger.debug(`No conversion required: ${type} already matches ${preferencesToString(preferences)}`);
return { value: type, weight };
}
@ -122,13 +125,13 @@ export class ChainedConverter extends RepresentationConverter {
// Generate paths from all converters that match the input type
let paths = await this.converters.reduce(async(matches: Promise<ConversionPath[]>, converter):
Promise<ConversionPath[]> => {
const inTypes = await converter.getInputTypes();
if (getTypeWeight(inType, inTypes) > 0) {
const outTypes = await converter.getOutputTypes(inType);
if (Object.keys(outTypes).length > 0) {
(await matches).push({
converters: [ converter ],
intermediateTypes: [],
inTypes,
outTypes: await converter.getOutputTypes(),
inType,
outTypes,
});
}
return matches;
@ -137,18 +140,18 @@ export class ChainedConverter extends RepresentationConverter {
// It's impossible for a path to have a higher weight than this value
const maxWeight = Math.max(...Object.values(outPreferences));
let bestPath = this.findBest(inType, outPreferences, paths);
paths = this.removeBadPaths(paths, maxWeight, inType, bestPath);
let bestPath = this.findBest(outPreferences, paths);
paths = this.removeBadPaths(paths, maxWeight, bestPath);
// This will always stop at some point since paths can't have the same converter twice
while (paths.length > 0) {
// For every path, find all the paths that can be made by adding 1 more converter
const promises = paths.map(async(path): Promise<ConversionPath[]> => this.takeStep(path));
paths = (await Promise.all(promises)).flat();
const newBest = this.findBest(inType, outPreferences, paths);
const newBest = this.findBest(outPreferences, paths);
if (newBest && (!bestPath || newBest.weight > bestPath.weight)) {
bestPath = newBest;
}
paths = this.removeBadPaths(paths, maxWeight, inType, bestPath);
paths = this.removeBadPaths(paths, maxWeight, bestPath);
}
if (!bestPath) {
@ -161,18 +164,17 @@ export class ChainedConverter extends RepresentationConverter {
}
/**
* Finds the path from the given list that can convert the given type to the given preferences.
* Finds the path from the given list that can convert to the given preferences.
* If there are multiple matches the one with the highest result weight gets chosen.
* Will return undefined if there are no matches.
*/
private findBest(type: string, preferences: ValuePreferences, paths: ConversionPath[]): MatchedPath | undefined {
private findBest(preferences: ValuePreferences, paths: ConversionPath[]): MatchedPath | undefined {
// Need to use null instead of undefined so `reduce` doesn't take the first element of the array as `best`
return paths.reduce((best: MatchedPath | null, path): MatchedPath | null => {
const outMatch = getBestPreference(path.outTypes, preferences);
if (outMatch && !(best && best.weight >= outMatch.weight)) {
// Create new MatchedPath, using the output match above
const inWeight = getTypeWeight(type, path.inTypes);
return { path, inType: type, outType: outMatch.value, weight: inWeight * outMatch.weight };
return { path, outType: outMatch.value, weight: outMatch.weight };
}
return best;
}, null) ?? undefined;
@ -184,11 +186,9 @@ export class ChainedConverter extends RepresentationConverter {
*
* @param paths - Paths to filter.
* @param maxWeight - The maximum weight in the output preferences.
* @param inType - The input type.
* @param bestMatch - The current best path.
*/
private removeBadPaths(paths: ConversionPath[], maxWeight: number, inType: string, bestMatch?: MatchedPath):
ConversionPath[] {
private removeBadPaths(paths: ConversionPath[], maxWeight: number, bestMatch?: MatchedPath): ConversionPath[] {
// All paths are still good if there is no best match yet
if (!bestMatch) {
return paths;
@ -200,9 +200,7 @@ export class ChainedConverter extends RepresentationConverter {
// Only return paths that can potentially improve upon bestPath
return paths.filter((path): boolean => {
const optimisticWeight = getTypeWeight(inType, path.inTypes) *
Math.max(...Object.values(path.outTypes)) *
maxWeight;
const optimisticWeight = Math.max(...Object.values(path.outTypes)) * maxWeight;
return optimisticWeight > bestMatch.weight;
});
}
@ -218,9 +216,9 @@ export class ChainedConverter extends RepresentationConverter {
// Create a new path for every converter that can be appended
return Promise.all(nextConverters.map(async(pref): Promise<ConversionPath> => ({
converters: [ ...path.converters, pref.converter ],
intermediateTypes: [ ...path.intermediateTypes, pref.value ],
inTypes: path.inTypes,
outTypes: this.modifyTypeWeights(pref.weight, await pref.converter.getOutputTypes()),
intermediateTypes: [ ...path.intermediateTypes, pref.inType ],
inType: path.inType,
outTypes: pref.outTypes,
})));
}
@ -237,13 +235,15 @@ export class ChainedConverter extends RepresentationConverter {
*/
private async supportedConverters(types: ValuePreferences, converters: TypedRepresentationConverter[]):
Promise<ConverterPreference[]> {
const promises = converters.map(async(converter): Promise<ConverterPreference | undefined> => {
const inputTypes = await converter.getInputTypes();
const match = getBestPreference(types, inputTypes);
if (match) {
return { ...match, converter };
const typeEntries = Object.entries(types);
const results: ConverterPreference[] = [];
for (const converter of converters) {
for (const [ inType, weight ] of typeEntries) {
let outTypes = await converter.getOutputTypes(inType);
outTypes = this.modifyTypeWeights(weight, outTypes);
results.push({ converter, inType, outTypes });
}
});
return (await Promise.all(promises)).filter(Boolean) as ConverterPreference[];
}
return results;
}
}

View File

@ -164,3 +164,13 @@ export function matchesMediaType(mediaA: string, mediaB: string): boolean {
export function isInternalContentType(contentType?: string): boolean {
return typeof contentType !== 'undefined' && matchesMediaType(contentType, INTERNAL_ALL);
}
/**
* Serializes a preferences object to a string for display purposes.
* @param preferences - Preferences to serialize
*/
export function preferencesToString(preferences: ValuePreferences): string {
return Object.entries(preferences)
.map(([ type, weight ]): string => `${type}:${weight}`)
.join(',');
}

View File

@ -43,7 +43,7 @@ export class ErrorToTemplateConverter extends BaseTypedRepresentationConverter {
private readonly contentType: string;
public constructor(templateEngine: TemplateEngine, templateOptions?: TemplateOptions) {
super(INTERNAL_ERROR, templateOptions?.contentType ?? DEFAULT_TEMPLATE_OPTIONS.contentType);
super(INTERNAL_ERROR, templateOptions?.contentType ?? DEFAULT_TEMPLATE_OPTIONS.contentType!);
// Workaround for https://github.com/LinkedSoftwareDependencies/Components.js/issues/20
if (!templateOptions || Object.keys(templateOptions).length === 0) {
templateOptions = DEFAULT_TEMPLATE_OPTIONS;

View File

@ -27,7 +27,7 @@ export class QuadToRdfConverter extends BaseTypedRepresentationConverter {
public async handle({ identifier, representation: quads, preferences }: RepresentationConverterArgs):
Promise<Representation> {
// Can not be undefined if the `canHandle` call passed
const contentType = getConversionTarget(await this.getOutputTypes(), preferences.type)!;
const contentType = getConversionTarget(await this.getOutputTypes(INTERNAL_QUADS), preferences.type)!;
let data: Readable;
// Use prefixes if possible (see https://github.com/rubensworks/rdf-serialize.js/issues/1)
@ -36,7 +36,7 @@ export class QuadToRdfConverter extends BaseTypedRepresentationConverter {
.map(({ subject, object }): [string, string] => [ object.value, subject.value ]));
const options = { format: contentType, baseIRI: identifier.path, prefixes };
data = pipeSafely(quads.data, new StreamWriter(options));
// Otherwise, write without prefixes
// Otherwise, write without prefixes
} else {
data = rdfSerializer.serialize(quads.data, { contentType }) as Readable;
}

View File

@ -6,12 +6,7 @@ import { RepresentationConverter } from './RepresentationConverter';
*/
export abstract class TypedRepresentationConverter extends RepresentationConverter {
/**
* Gets the supported input content types for this converter, mapped to a numerical priority.
* Gets the output content types this converter can convert the input type to, mapped to a numerical priority.
*/
public abstract getInputTypes(): Promise<ValuePreferences>;
/**
* Gets the supported output content types for this converter, mapped to a numerical quality.
*/
public abstract getOutputTypes(): Promise<ValuePreferences>;
public abstract getOutputTypes(contentType: string): Promise<ValuePreferences>;
}

View File

@ -22,15 +22,7 @@ const logger: jest.Mocked<Logger> = getLoggerFor('GuardedStream') as any;
class DummyConverter extends BaseTypedRepresentationConverter {
public constructor() {
super('*/*', 'custom/type');
}
public async getInputTypes(): Promise<Record<string, number>> {
return { [INTERNAL_QUADS]: 1 };
}
public async getOutputTypes(): Promise<Record<string, number>> {
return { 'x/x': 1 };
super(INTERNAL_QUADS, 'x/x');
}
public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {

View File

@ -7,56 +7,37 @@ class CustomTypedRepresentationConverter extends BaseTypedRepresentationConverte
}
describe('A BaseTypedRepresentationConverter', (): void => {
it('defaults to allowing everything.', async(): Promise<void> => {
const converter = new CustomTypedRepresentationConverter();
await expect(converter.getInputTypes()).resolves.toEqual({
});
await expect(converter.getOutputTypes()).resolves.toEqual({
});
});
it('accepts strings.', async(): Promise<void> => {
const converter = new CustomTypedRepresentationConverter('a/b', 'c/d');
await expect(converter.getInputTypes()).resolves.toEqual({
'a/b': 1,
});
await expect(converter.getOutputTypes()).resolves.toEqual({
await expect(converter.getOutputTypes('a/b')).resolves.toEqual({
'c/d': 1,
});
});
it('accepts string arrays.', async(): Promise<void> => {
const converter = new CustomTypedRepresentationConverter([ 'a/b', 'c/d' ], [ 'e/f', 'g/h' ]);
await expect(converter.getInputTypes()).resolves.toEqual({
'a/b': 1,
'c/d': 1,
});
await expect(converter.getOutputTypes()).resolves.toEqual({
'e/f': 1,
'g/h': 1,
});
const output = { 'e/f': 1, 'g/h': 1 };
await expect(converter.getOutputTypes('a/b')).resolves.toEqual(output);
await expect(converter.getOutputTypes('c/d')).resolves.toEqual(output);
});
it('accepts records.', async(): Promise<void> => {
const converter = new CustomTypedRepresentationConverter({ 'a/b': 0.5 }, { 'c/d': 0.5 });
await expect(converter.getInputTypes()).resolves.toEqual({
'a/b': 0.5,
});
await expect(converter.getOutputTypes()).resolves.toEqual({
'c/d': 0.5,
await expect(converter.getOutputTypes('a/b')).resolves.toEqual({
'c/d': 0.5 * 0.5,
});
});
it('can not handle input without a Content-Type.', async(): Promise<void> => {
const args: RepresentationConverterArgs = { representation: { metadata: { }}, preferences: {}} as any;
const converter = new CustomTypedRepresentationConverter('*/*');
const converter = new CustomTypedRepresentationConverter('*/*', 'b/b');
await expect(converter.canHandle(args)).rejects.toThrow(NotImplementedHttpError);
});
it('can not handle a type that does not match the input types.', async(): Promise<void> => {
const args: RepresentationConverterArgs =
{ representation: { metadata: { contentType: 'b/b' }}, preferences: {}} as any;
const converter = new CustomTypedRepresentationConverter('a/a');
const converter = new CustomTypedRepresentationConverter('a/a', 'b/b');
await expect(converter.canHandle(args)).rejects.toThrow(NotImplementedHttpError);
});

View File

@ -15,19 +15,11 @@ class DummyConverter extends BaseTypedRepresentationConverter {
private readonly outTypes: ValuePreferences;
public constructor(inTypes: ValuePreferences, outTypes: ValuePreferences) {
super();
super(inTypes, outTypes);
this.inTypes = inTypes;
this.outTypes = outTypes;
}
public async getInputTypes(): Promise<ValuePreferences> {
return this.inTypes;
}
public async getOutputTypes(): Promise<ValuePreferences> {
return this.outTypes;
}
public async handle(input: RepresentationConverterArgs): Promise<Representation> {
// Make sure the input type is supported
const inType = input.representation.metadata.contentType!;

View File

@ -6,7 +6,7 @@ import {
getTypeWeight,
getWeightedPreferences, isInternalContentType,
matchesMediaPreferences,
matchesMediaType,
matchesMediaType, preferencesToString,
} from '../../../../src/storage/conversion/ConversionUtil';
import { InternalServerError } from '../../../../src/util/errors/InternalServerError';
@ -153,4 +153,11 @@ describe('ConversionUtil', (): void => {
expect(isInternalContentType('text/turtle')).toBeFalsy();
});
});
describe('#preferencesToString', (): void => {
it('returns a string serialization.', async(): Promise<void> => {
const preferences: ValuePreferences = { 'a/*': 1, 'b/b': 0.8, 'c/c': 0 };
expect(preferencesToString(preferences)).toEqual('a/*:1,b/b:0.8,c/c:0');
});
});
});

View File

@ -9,8 +9,7 @@ describe('An ErrorToJsonConverter', (): void => {
const preferences = {};
it('supports going from errors to json.', async(): Promise<void> => {
await expect(converter.getInputTypes()).resolves.toEqual({ 'internal/error': 1 });
await expect(converter.getOutputTypes()).resolves.toEqual({ 'application/json': 1 });
await expect(converter.getOutputTypes('internal/error')).resolves.toEqual({ 'application/json': 1 });
});
it('adds all HttpError fields.', async(): Promise<void> => {

View File

@ -13,8 +13,7 @@ describe('An ErrorToQuadConverter', (): void => {
const preferences = {};
it('supports going from errors to quads.', async(): Promise<void> => {
await expect(converter.getInputTypes()).resolves.toEqual({ 'internal/error': 1 });
await expect(converter.getOutputTypes()).resolves.toEqual({ 'internal/quads': 1 });
await expect(converter.getOutputTypes('internal/error')).resolves.toEqual({ 'internal/quads': 1 });
});
it('adds triples for all error fields.', async(): Promise<void> => {

View File

@ -24,8 +24,7 @@ describe('An ErrorToTemplateConverter', (): void => {
});
it('supports going from errors to the given content type.', async(): Promise<void> => {
await expect(converter.getInputTypes()).resolves.toEqual({ 'internal/error': 1 });
await expect(converter.getOutputTypes()).resolves.toEqual({ 'text/html': 1 });
await expect(converter.getOutputTypes('internal/error')).resolves.toEqual({ 'text/html': 1 });
});
it('works with non-HTTP errors.', async(): Promise<void> => {

View File

@ -8,8 +8,8 @@ describe('A FormToJsonConverter', (): void => {
const converter = new FormToJsonConverter();
it('supports going from form data to json.', async(): Promise<void> => {
await expect(converter.getInputTypes()).resolves.toEqual({ 'application/x-www-form-urlencoded': 1 });
await expect(converter.getOutputTypes()).resolves.toEqual({ 'application/json': 1 });
await expect(converter.getOutputTypes('application/x-www-form-urlencoded'))
.resolves.toEqual({ 'application/json': 1 });
});
it('converts form data to JSON.', async(): Promise<void> => {

View File

@ -17,8 +17,7 @@ describe('A MarkdownToHtmlConverter', (): void => {
});
it('supports going from markdown to html.', async(): Promise<void> => {
await expect(converter.getInputTypes()).resolves.toEqual({ 'text/markdown': 1 });
await expect(converter.getOutputTypes()).resolves.toEqual({ 'text/html': 1 });
await expect(converter.getOutputTypes('text/markdown')).resolves.toEqual({ 'text/html': 1 });
});
it('converts markdown and inserts it in the template.', async(): Promise<void> => {

View File

@ -19,19 +19,14 @@ describe('A QuadToRdfConverter', (): void => {
metadata = new RepresentationMetadata(identifier, INTERNAL_QUADS);
});
it('supports parsing quads.', async(): Promise<void> => {
await expect(new QuadToRdfConverter().getInputTypes())
.resolves.toEqual({ [INTERNAL_QUADS]: 1 });
});
it('defaults to rdfSerializer preferences when given no output preferences.', async(): Promise<void> => {
await expect(new QuadToRdfConverter().getOutputTypes())
await expect(new QuadToRdfConverter().getOutputTypes(INTERNAL_QUADS))
.resolves.toEqual(await rdfSerializer.getContentTypesPrioritized());
});
it('supports overriding output preferences.', async(): Promise<void> => {
const outputPreferences = { 'text/turtle': 1 };
await expect(new QuadToRdfConverter({ outputPreferences }).getOutputTypes())
await expect(new QuadToRdfConverter({ outputPreferences }).getOutputTypes(INTERNAL_QUADS))
.resolves.toEqual(outputPreferences);
});

View File

@ -16,12 +16,11 @@ describe('A RdfToQuadConverter', (): void => {
const converter = new RdfToQuadConverter();
const identifier: ResourceIdentifier = { path: 'path' };
it('supports parsing the same types as rdfParser.', async(): Promise<void> => {
await expect(converter.getInputTypes()).resolves.toEqual(await rdfParser.getContentTypesPrioritized());
});
it('supports serializing as quads.', async(): Promise<void> => {
await expect(converter.getOutputTypes()).resolves.toEqual({ [INTERNAL_QUADS]: 1 });
const types = Object.entries(await rdfParser.getContentTypesPrioritized());
for (const [ type, weight ] of types) {
await expect(converter.getOutputTypes(type)).resolves.toEqual({ [INTERNAL_QUADS]: weight });
}
});
it('can handle turtle to quad conversions.', async(): Promise<void> => {