-
Notifications
You must be signed in to change notification settings - Fork 121
/
ChainedConverter.ts
297 lines (268 loc) · 11.9 KB
/
ChainedConverter.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
import type { Representation } from '../../ldp/representation/Representation';
import type { ValuePreference, ValuePreferences } from '../../ldp/representation/RepresentationPreferences';
import { getLoggerFor } from '../../logging/LogUtil';
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { cleanPreferences, getBestPreference, getTypeWeight } from './ConversionUtil';
import type { RepresentationConverterArgs } from './RepresentationConverter';
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 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 RepresentationConverter {
protected readonly logger = getLoggerFor(this);
private readonly converters: TypedRepresentationConverter[];
private readonly cache: LruPathCache;
public constructor(converters: TypedRepresentationConverter[], maxCacheSize = 50) {
super();
if (converters.length === 0) {
throw new Error('At least 1 converter is required.');
}
this.converters = [ ...converters ];
this.cache = new LruPathCache(maxCacheSize);
}
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 < path.converters.length - 1; ++i) {
const type = path.intermediateTypes[i];
args.preferences = { type: { [type]: 1 }};
args.representation = await path.converters[i].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 a conversion path that can handle the given input,
* either in the cache or by generating a new one.
*/
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 (!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);
// 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[];
}
}