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>;
}