Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Update ChainedConverter to create dynamic paths
- Loading branch information
Showing
4 changed files
with
483 additions
and
111 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,89 +1,297 @@ | ||
import type { Representation } from '../../ldp/representation/Representation'; | ||
import type { ValuePreference, ValuePreferences } from '../../ldp/representation/RepresentationPreferences'; | ||
import { getLoggerFor } from '../../logging/LogUtil'; | ||
import { InternalServerError } from '../../util/errors/InternalServerError'; | ||
import { matchesMediaType } from './ConversionUtil'; | ||
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; | ||
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; | ||
import { cleanPreferences, getBestPreference, getTypeWeight } from './ConversionUtil'; | ||
import type { RepresentationConverterArgs } from './RepresentationConverter'; | ||
import { TypedRepresentationConverter } from './TypedRepresentationConverter'; | ||
import { RepresentationConverter } from './RepresentationConverter'; | ||
import type { TypedRepresentationConverter } from './TypedRepresentationConverter'; | ||
|
||
type ConverterPreference = ValuePreference & { converter: TypedRepresentationConverter }; | ||
|
||
/** | ||
* A chain of converters that can go from `inTypes` to `outTypes`. | ||
* `intermediateTypes` contains the exact types that have the highest weight when going from converter i to i+1. | ||
*/ | ||
type ConversionPath = { | ||
converters: TypedRepresentationConverter[]; | ||
intermediateTypes: string[]; | ||
inTypes: ValuePreferences; | ||
outTypes: ValuePreferences; | ||
}; | ||
|
||
/** | ||
* The result of applying a `ConversionPath` to a specific input. | ||
*/ | ||
type MatchedPath = { | ||
path: ConversionPath; | ||
inType: string; | ||
outType: string; | ||
weight: number; | ||
}; | ||
|
||
/** | ||
* An LRU cache for storing `ConversionPath`s. | ||
*/ | ||
class LruPathCache { | ||
private readonly maxSize: number; | ||
// Contents are ordered from least to most recently used | ||
private readonly paths: ConversionPath[] = []; | ||
|
||
public constructor(maxSize: number) { | ||
this.maxSize = maxSize; | ||
} | ||
|
||
/** | ||
* Add the given path to the cache as most recently used. | ||
*/ | ||
public add(path: ConversionPath): void { | ||
this.paths.push(path); | ||
if (this.paths.length > this.maxSize) { | ||
this.paths.shift(); | ||
} | ||
} | ||
|
||
/** | ||
* Find a path that can convert the given type to the given preferences. | ||
* Note that this finds the first matching path in the cache, | ||
* not the best one, should there be multiple results. | ||
* In practice this should almost never be the case though. | ||
*/ | ||
public find(inType: string, outPreferences: ValuePreferences): MatchedPath | undefined { | ||
// Last element is most recently used so has more chance of being the correct one | ||
for (let i = this.paths.length - 1; i >= 0; --i) { | ||
const path = this.paths[i]; | ||
// Check if `inType` matches the input and `outPreferences` the output types of the path | ||
const match = this.getMatchedPath(inType, outPreferences, path); | ||
if (match) { | ||
// Set matched path to most recent result in the cache | ||
this.paths.splice(i, 1); | ||
this.paths.push(path); | ||
return match; | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Calculates the weights and exact types when using the given path on the given type and preferences. | ||
* Undefined if there is no match | ||
*/ | ||
private getMatchedPath(inType: string, outPreferences: ValuePreferences, path: ConversionPath): | ||
MatchedPath | undefined { | ||
const inWeight = getTypeWeight(inType, path.inTypes); | ||
if (inWeight === 0) { | ||
return; | ||
} | ||
const outMatch = getBestPreference(path.outTypes, outPreferences); | ||
if (!outMatch) { | ||
return; | ||
} | ||
return { path, inType, outType: outMatch.value, weight: inWeight * outMatch.weight }; | ||
} | ||
} | ||
|
||
/** | ||
* A meta converter that takes an array of other converters as input. | ||
* It chains these converters by finding intermediate types that are supported by converters on either side. | ||
* It chains these converters by finding a path of converters | ||
* that can go from the given content-type to the given type preferences. | ||
* In case there are multiple paths, the shortest one with the highest weight gets found. | ||
* Will error in case no path can be found. | ||
* | ||
* Generated paths get stored in an internal cache for later re-use on similar requests. | ||
* Note that due to this caching `RepresentationConverter`s | ||
* that change supported input/output types at runtime are not supported, | ||
* unless cache size is set to 0. | ||
* | ||
* This is not a TypedRepresentationConverter since the supported output types | ||
* might depend on what is the input content-type. | ||
* | ||
* Some suggestions on how this class can be even more optimized should this ever be needed in the future. | ||
* Most of these decrease computation time at the cost of more memory. | ||
* - Subpaths that are generated could also be cached. | ||
* - When looking for the next step, cached paths could also be considered. | ||
* - The algorithm could start on both ends of a possible path and work towards the middle. | ||
* - When creating a path, store the list of unused converters instead of checking every step. | ||
*/ | ||
export class ChainedConverter extends TypedRepresentationConverter { | ||
export class ChainedConverter extends RepresentationConverter { | ||
protected readonly logger = getLoggerFor(this); | ||
|
||
private readonly converters: TypedRepresentationConverter[]; | ||
private readonly cache: LruPathCache; | ||
|
||
/** | ||
* Creates the chain of converters based on the input. | ||
* The list of `converters` needs to be at least 2 long. | ||
* @param converters - The chain of converters. | ||
*/ | ||
public constructor(converters: TypedRepresentationConverter[]) { | ||
public constructor(converters: TypedRepresentationConverter[], maxCacheSize = 50) { | ||
super(); | ||
if (converters.length < 2) { | ||
throw new Error('At least 2 converters are required.'); | ||
if (converters.length === 0) { | ||
throw new Error('At least 1 converter is required.'); | ||
} | ||
this.converters = [ ...converters ]; | ||
this.inputTypes = this.first.getInputTypes(); | ||
this.outputTypes = this.last.getOutputTypes(); | ||
this.cache = new LruPathCache(maxCacheSize); | ||
} | ||
|
||
protected get first(): TypedRepresentationConverter { | ||
return this.converters[0]; | ||
} | ||
|
||
protected get last(): TypedRepresentationConverter { | ||
return this.converters[this.converters.length - 1]; | ||
public async canHandle(input: RepresentationConverterArgs): Promise<void> { | ||
// Will cache the path if found, and error if not | ||
await this.findPath(input); | ||
} | ||
|
||
public async handle(input: RepresentationConverterArgs): Promise<Representation> { | ||
const match = await this.findPath(input); | ||
|
||
// No conversion needed | ||
if (!this.isMatchedPath(match)) { | ||
return input.representation; | ||
} | ||
|
||
const { path } = match; | ||
this.logger.debug(`Converting ${match.inType} -> ${path.intermediateTypes.join(' -> ')} -> ${match.outType}.`); | ||
|
||
const args = { ...input }; | ||
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]: 1 }}; | ||
args.representation = await this.converters[i].handle(args); | ||
for (let i = 0; i < path.converters.length - 1; ++i) { | ||
const type = path.intermediateTypes[i]; | ||
args.preferences = { type: { [type]: 1 }}; | ||
args.representation = await path.converters[i].handle(args); | ||
} | ||
args.preferences = input.preferences; | ||
return this.last.handle(args); | ||
// For the last converter we set the preferences to the best output type | ||
args.preferences = { type: { [match.outType]: 1 }}; | ||
return path.converters.slice(-1)[0].handle(args); | ||
} | ||
|
||
public async handleSafe(input: RepresentationConverterArgs): Promise<Representation> { | ||
// This way we don't run `findPath` twice, even though it would be cached for the second call | ||
return this.handle(input); | ||
} | ||
|
||
private isMatchedPath(path: unknown): path is MatchedPath { | ||
return typeof (path as MatchedPath).path === 'object'; | ||
} | ||
|
||
/** | ||
* Finds the best media type that can be used to chain 2 converters. | ||
* Finds a conversion path that can handle the given input, | ||
* either in the cache or by generating a new one. | ||
*/ | ||
protected async getMatchingType(left: TypedRepresentationConverter, right: TypedRepresentationConverter): | ||
Promise<string> { | ||
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 && matchesMediaType(leftType, rightType)) { | ||
bestMatch = { type: leftType, weight }; | ||
if (weight === 1) { | ||
this.logger.debug(`${bestMatch.type} is an exact match between ${leftKeys} and ${rightKeys}`); | ||
return bestMatch.type; | ||
} | ||
} | ||
private async findPath(input: RepresentationConverterArgs): Promise<MatchedPath | ValuePreference> { | ||
const type = input.representation.metadata.contentType; | ||
if (!type) { | ||
throw new BadRequestHttpError('Missing Content-Type header.'); | ||
} | ||
let preferences = input.preferences.type; | ||
if (!preferences) { | ||
throw new BadRequestHttpError('Missing type preferences.'); | ||
} | ||
preferences = cleanPreferences(preferences); | ||
|
||
const weight = getTypeWeight(type, preferences); | ||
if (weight > 0) { | ||
this.logger.debug(`No conversion required: ${type} already matches ${Object.keys(input.preferences.type!)}`); | ||
return { value: type, weight }; | ||
} | ||
|
||
// Use a cached solution if we have one. | ||
// Note that it's possible that a better one could be generated. | ||
// But this is usually highly unlikely. | ||
let match = this.cache.find(type, preferences); | ||
if (!match) { | ||
match = await this.generatePath(type, preferences); | ||
this.cache.add(match.path); | ||
} | ||
return match; | ||
} | ||
|
||
/** | ||
* Tries to generate the optimal and shortest `ConversionPath` that supports the given parameters, | ||
* which will then be used to instantiate a specific `MatchedPath` for those parameters. | ||
* | ||
* Errors if such a path does not exist. | ||
*/ | ||
private async generatePath(inType: string, outPreferences: ValuePreferences): Promise<MatchedPath> { | ||
// 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) { | ||
(await matches).push({ | ||
converters: [ converter ], | ||
intermediateTypes: [], | ||
inTypes, | ||
outTypes: await converter.getOutputTypes(), | ||
}); | ||
} | ||
return matches; | ||
}, Promise.resolve([])); | ||
|
||
let bestPath = this.findBest(inType, outPreferences, paths); | ||
// This will always stop at some point since paths can't have the same converter twice | ||
while (!bestPath && 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(); | ||
bestPath = this.findBest(inType, outPreferences, paths); | ||
} | ||
|
||
if (bestMatch.weight === 0) { | ||
this.logger.warn(`No match found between ${leftKeys} and ${rightKeys}`); | ||
throw new InternalServerError(`No match found between ${leftKeys} and ${rightKeys}`); | ||
if (!bestPath) { | ||
this.logger.warn(`No conversion path could be made from ${inType} to ${Object.keys(outPreferences)}.`); | ||
throw new NotImplementedHttpError( | ||
`No conversion path could be made from ${inType} to ${Object.keys(outPreferences)}.`, | ||
); | ||
} | ||
return bestPath; | ||
} | ||
|
||
/** | ||
* Finds the path from the given list that can convert the given type 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 { | ||
// 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 best; | ||
}, null) ?? undefined; | ||
} | ||
|
||
/** | ||
* Finds all converters that could take the output of the given path as input. | ||
* For each of these converters a new path gets created which is the input path appended by the converter. | ||
*/ | ||
private async takeStep(path: ConversionPath): Promise<ConversionPath[]> { | ||
const unusedConverters = this.converters.filter((converter): boolean => !path.converters.includes(converter)); | ||
const nextConverters = await this.supportedConverters(path.outTypes, unusedConverters); | ||
|
||
this.logger.debug(`${bestMatch.type} is the best match between ${leftKeys} and ${rightKeys}`); | ||
return bestMatch.type; | ||
// 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()), | ||
}))); | ||
} | ||
|
||
/** | ||
* Creates a new ValuePreferences object, which is equal to the input object | ||
* with all values multiplied by the given weight. | ||
*/ | ||
private modifyTypeWeights(weight: number, types: ValuePreferences): ValuePreferences { | ||
return Object.fromEntries(Object.entries(types).map(([ type, pref ]): [string, number] => [ type, weight * pref ])); | ||
} | ||
|
||
/** | ||
* Finds all converters in the given list that support taking any of the given types as input. | ||
*/ | ||
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 }; | ||
} | ||
}); | ||
return (await Promise.all(promises)).filter(Boolean) as ConverterPreference[]; | ||
} | ||
} |
Oops, something went wrong.