mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Determine Typed Converter output based on input type
This commit is contained in:
@@ -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)}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(',');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user