Skip to content

Commit

Permalink
refactor: Extend and update the ConversionUtil functions
Browse files Browse the repository at this point in the history
  • Loading branch information
joachimvh committed Apr 27, 2021
1 parent 420e497 commit 87a5401
Show file tree
Hide file tree
Showing 10 changed files with 262 additions and 136 deletions.
6 changes: 6 additions & 0 deletions src/ldp/representation/RepresentationPreferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
*/
export type ValuePreferences = Record<string, number>;

/**
* A single entry of a {@link ValuePreferences} object.
* Useful when doing operations on such an object.
*/
export type ValuePreference = { value: string; weight: number };

/**
* Contains preferences along multiple content negotiation dimensions.
*
Expand Down
4 changes: 2 additions & 2 deletions src/storage/conversion/ConstantConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createReadStream } from 'fs';
import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation';
import type { Representation } from '../../ldp/representation/Representation';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { hasMatchingMediaTypes, matchesMediaType } from './ConversionUtil';
import { matchesMediaType, matchesMediaPreferences } from './ConversionUtil';
import { RepresentationConverter } from './RepresentationConverter';
import type { RepresentationConverterArgs } from './RepresentationConverter';

Expand Down Expand Up @@ -37,7 +37,7 @@ export class ConstantConverter extends RepresentationConverter {
if (!preferences.type) {
throw new NotImplementedHttpError('No content type preferences specified');
}
if (!hasMatchingMediaTypes({ ...preferences.type, '*/*': 0 }, { [this.contentType]: 1 })) {
if (!matchesMediaPreferences(this.contentType, { ...preferences.type, '*/*': 0 })) {
throw new NotImplementedHttpError(`No preference for ${this.contentType}`);
}

Expand Down
8 changes: 4 additions & 4 deletions src/storage/conversion/ContentTypeReplacer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Representation } from '../../ldp/representation/Representation';
import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
import type { ValuePreferences } from '../../ldp/representation/RepresentationPreferences';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { matchesMediaType, matchingMediaTypes } from './ConversionUtil';
import { matchesMediaType, getConversionTarget } from './ConversionUtil';
import type { RepresentationConverterArgs } from './RepresentationConverter';
import { RepresentationConverter } from './RepresentationConverter';

Expand Down Expand Up @@ -65,10 +65,10 @@ export class ContentTypeReplacer extends RepresentationConverter {
const supported = Object.keys(this.contentTypeMap)
.filter((type): boolean => matchesMediaType(contentType, type))
.map((type): ValuePreferences => this.contentTypeMap[type]);
const matching = matchingMediaTypes(preferred, Object.assign({} as ValuePreferences, ...supported));
if (matching.length === 0) {
const match = getConversionTarget(Object.assign({} as ValuePreferences, ...supported), preferred);
if (!match) {
throw new NotImplementedHttpError(`Cannot convert from ${contentType} to ${Object.keys(preferred)}`);
}
return matching[0];
return match;
}
}
172 changes: 102 additions & 70 deletions src/storage/conversion/ConversionUtil.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,133 @@
import type { ValuePreferences } from '../../ldp/representation/RepresentationPreferences';
import type { ValuePreference, ValuePreferences } from '../../ldp/representation/RepresentationPreferences';
import { INTERNAL_ALL } from '../../util/ContentTypes';
import { InternalServerError } from '../../util/errors/InternalServerError';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';

/**
* Filters media types based on the given preferences.
* Based on RFC 7231 - Content negotiation.
* Will add a default `internal/*;q=0` to the preferences to prevent accidental use of internal types.
* Since more specific media ranges override less specific ones,
* this will be ignored if there is a specific internal type preference.
* Cleans incoming preferences to prevent unwanted behaviour.
* Makes sure internal types have weight 0, unless specifically requested in the preferences,
* and interprets empty preferences as accepting everything.
*
* This should be called even if there are no preferredTypes since this also filters out internal types.
* @param preferences - Preferences that need to be updated.
*
* @param preferredTypes - Preferences for output type.
* @param availableTypes - Media types to compare to the preferences.
*
* @throws BadRequestHttpError
* If the type preferences are undefined or if there are duplicate preferences.
*
* @returns The weighted and filtered list of matching types.
* @returns A copy of the the preferences with the necessary updates.
*/
export function matchingMediaTypes(preferredTypes: ValuePreferences = {}, availableTypes: ValuePreferences = {}):
string[] {
export function cleanPreferences(preferences: ValuePreferences = {}): ValuePreferences {
// No preference means anything is acceptable
const preferred = { ...preferredTypes };
if (Object.keys(preferredTypes).length === 0) {
const preferred = { ...preferences };
if (Object.keys(preferences).length === 0) {
preferred['*/*'] = 1;
}
// Prevent accidental use of internal types
if (!(INTERNAL_ALL in preferred)) {
preferred[INTERNAL_ALL] = 0;
}
return preferred;
}

/**
* Tries to match the given type to the given preferences.
* In case there are multiple matches the most specific one will be chosen as per RFC 7231.
*
* @param type - Type for which the matching weight is needed.
* @param preferred - Preferences to match the type to.
*
* @returns The corresponding weight from the preferences or 0 if there is no match.
*/
export function getTypeWeight(type: string, preferred: ValuePreferences): number {
const match = /^([^/]+)\/([^\s;]+)/u.exec(type);
if (!match) {
throw new InternalServerError(`Unexpected media type: ${type}.`);
}
const [ , main, sub ] = match;
// RFC 7231
// Media ranges can be overridden by more specific media ranges or
// specific media types. If more than one media range applies to a
// given type, the most specific reference has precedence.
const weightedSupported = Object.entries(availableTypes).map(([ type, quality ]): [string, number] => {
const match = /^([^/]+)\/([^\s;]+)/u.exec(type);
if (!match) {
throw new InternalServerError(`Unexpected type preference: ${type}`);
}
const [ , main, sub ] = match;
const weight =
preferred[type] ??
preferred[`${main}/${sub}`] ??
preferred[`${main}/*`] ??
preferred['*/*'] ??
0;
return [ type, weight * quality ];
});
return preferred[type] ??
preferred[`${main}/${sub}`] ??
preferred[`${main}/*`] ??
preferred['*/*'] ??
0;
}

// Return all non-zero preferences in descending order of weight
/**
* Measures the weights for all the given types when matched against the given preferences.
* Results will be sorted by weight.
* Weights of 0 indicate that no match is possible.
*
* @param types - Types for which we want to calculate the weights.
* @param preferred - Preferences to match the types against.
*
* @returns An array with a {@link ValuePreference} object for every input type, sorted by calculated weight.
*/
export function getWeightedPreferences(types: ValuePreferences, preferred: ValuePreferences): ValuePreference[] {
const weightedSupported = Object.entries(types)
.map(([ value, quality ]): ValuePreference => ({ value, weight: quality * getTypeWeight(value, preferred) }));
return weightedSupported
.filter(([ , weight ]): boolean => weight !== 0)
.sort(([ , weightA ], [ , weightB ]): number => weightB - weightA)
.map(([ type ]): string => type);
.sort(({ weight: weightA }, { weight: weightB }): number => weightB - weightA);
}

/**
* Determines whether any available type satisfies the preferences.
* Finds the best result from the `available` values when matching against the `preferred` preferences.
* `undefined` if there is no match.
*/
/**
* Finds the type from the given types that has the best match with the given preferences,
* based on the calculated weight.
*
* @param preferredTypes - Preferences for output type.
* @param availableTypes - Media types to compare to the preferences.
* @param types - Types for which we want to find the best match.
* @param preferred - Preferences to match the types against.
*
* @throws BadRequestHttpError
* If the type preferences are undefined or if there are duplicate preferences.
* @returns A {@link ValuePreference} containing the best match and the corresponding weight.
* Undefined if there is no match.
*/
export function getBestPreference(types: ValuePreferences, preferred: ValuePreferences): ValuePreference | undefined {
// Could also return the first entry of the above function but this is more efficient
const result = Object.entries(types).reduce((best, [ value, quality ]): ValuePreference => {
if (best.weight >= quality) {
return best;
}
const weight = quality * getTypeWeight(value, preferred);
if (weight > best.weight) {
return { value, weight };
}
return best;
}, { value: '', weight: 0 });

if (result.weight > 0) {
return result;
}
}

/**
* For a media type converter that can generate the given types,
* this function tries to find the type that best matches the given preferences.
*
* This function combines several other conversion utility functions
* to determine what output a converter should generate:
* it cleans the preferences with {@link cleanPreferences} to support empty preferences
* and to prevent the accidental generation of internal types,
* after which the best match gets found based on the weights.
*
* @param types - Media types that can be converted to.
* @param preferred - Preferences for output type.
*
* @returns The best match. Undefined if there is no match.
*/
export function getConversionTarget(types: ValuePreferences, preferred: ValuePreferences = {}): string | undefined {
const cleaned = cleanPreferences(preferred);

return getBestPreference(types, cleaned)?.value;
}

/**
* Checks if the given type matches the given preferences.
*
* @returns Whether there is at least one preference match.
* @param type - Type to match.
* @param preferred - Preferences to match against.
*/
export function hasMatchingMediaTypes(preferredTypes?: ValuePreferences, availableTypes?: ValuePreferences): boolean {
return matchingMediaTypes(preferredTypes, availableTypes).length !== 0;
export function matchesMediaPreferences(type: string, preferred?: ValuePreferences): boolean {
return getTypeWeight(type, cleanPreferences(preferred)) > 0;
}

/**
Expand Down Expand Up @@ -99,28 +156,3 @@ export function matchesMediaType(mediaA: string, mediaB: string): boolean {
}
return subTypeA === subTypeB;
}

/**
* Determines whether the given conversion request is supported,
* given the available content type conversions:
* - Checks if there is a content type for the input.
* - Checks if the input type is supported by the parser.
* - Checks if the parser can produce one of the preferred output types.
* Throws an error with details if conversion is not possible.
* @param inputType - Actual input type.
* @param outputTypes - Acceptable output types.
* @param convertorIn - Media types that can be parsed by the converter.
* @param convertorOut - Media types that can be produced by the converter.
*/
export function supportsMediaTypeConversion(
inputType = 'unknown', outputTypes: ValuePreferences = {},
convertorIn: ValuePreferences = {}, convertorOut: ValuePreferences = {},
): void {
if (!Object.keys(convertorIn).some((type): boolean => matchesMediaType(inputType, type)) ||
matchingMediaTypes(outputTypes, convertorOut).length === 0) {
throw new NotImplementedHttpError(
`Cannot convert from ${inputType} to ${Object.keys(outputTypes)
}, only from ${Object.keys(convertorIn)} to ${Object.keys(convertorOut)}.`,
);
}
}
4 changes: 2 additions & 2 deletions src/storage/conversion/IfNeededConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Representation } from '../../ldp/representation/Representation';
import { getLoggerFor } from '../../logging/LogUtil';
import { InternalServerError } from '../../util/errors/InternalServerError';
import { UnsupportedAsyncHandler } from '../../util/handlers/UnsupportedAsyncHandler';
import { hasMatchingMediaTypes } from './ConversionUtil';
import { matchesMediaPreferences } from './ConversionUtil';
import { RepresentationConverter } from './RepresentationConverter';
import type { RepresentationConverterArgs } from './RepresentationConverter';

Expand Down Expand Up @@ -41,7 +41,7 @@ export class IfNeededConverter extends RepresentationConverter {
if (!contentType) {
throw new InternalServerError('Content-Type is required for data conversion.');
}
const noMatchingMediaType = !hasMatchingMediaTypes(preferences.type, { [contentType]: 1 });
const noMatchingMediaType = !matchesMediaPreferences(contentType, preferences.type);
if (noMatchingMediaType) {
this.logger.debug(`Conversion needed for ${identifier
.path} from ${representation.metadata.contentType} to satisfy ${!preferences.type ?
Expand Down
5 changes: 3 additions & 2 deletions src/storage/conversion/QuadToRdfConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { ValuePreferences } from '../../ldp/representation/RepresentationPr
import { INTERNAL_QUADS } from '../../util/ContentTypes';
import { pipeSafely } from '../../util/StreamUtil';
import { PREFERRED_PREFIX_TERM } from '../../util/Vocabularies';
import { matchingMediaTypes } from './ConversionUtil';
import { getConversionTarget } from './ConversionUtil';
import type { RepresentationConverterArgs } from './RepresentationConverter';
import { TypedRepresentationConverter } from './TypedRepresentationConverter';

Expand All @@ -26,7 +26,8 @@ export class QuadToRdfConverter extends TypedRepresentationConverter {

public async handle({ identifier, representation: quads, preferences }: RepresentationConverterArgs):
Promise<Representation> {
const contentType = matchingMediaTypes(preferences.type, await this.getOutputTypes())[0];
// Can not be undefined if the `canHandle` call passed
const contentType = getConversionTarget(await this.getOutputTypes(), preferences.type)!;
let data: Readable;

// Use prefixes if possible (see https://github.com/rubensworks/rdf-serialize.js/issues/1)
Expand Down
23 changes: 20 additions & 3 deletions src/storage/conversion/TypedRepresentationConverter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ValuePreferences } from '../../ldp/representation/RepresentationPreferences';
import { supportsMediaTypeConversion } from './ConversionUtil';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { getConversionTarget, getTypeWeight } from './ConversionUtil';
import { RepresentationConverter } from './RepresentationConverter';
import type { RepresentationConverterArgs } from './RepresentationConverter';

Expand Down Expand Up @@ -48,12 +49,28 @@ export abstract class TypedRepresentationConverter extends RepresentationConvert
}

/**
* Verifies whether this converter supports the input.
* Determines whether the given conversion request is supported,
* given the available content type conversions:
* - Checks if there is a content type for the input.
* - Checks if the input type is supported by the parser.
* - Checks if the parser can produce one of the preferred output types.
* 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);
supportsMediaTypeConversion(contentType, args.preferences.type, inputTypes, outputTypes);
const outputPreferences = args.preferences.type ?? {};
if (getTypeWeight(contentType, inputTypes) === 0 || !getConversionTarget(outputTypes, outputPreferences)) {
throw new NotImplementedHttpError(
`Cannot convert from ${contentType} to ${Object.keys(outputPreferences)
}, only from ${Object.keys(inputTypes)} to ${Object.keys(outputTypes)}.`,
);
}
}
}
4 changes: 4 additions & 0 deletions test/integration/GuardedStream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ jest.mock('../../src/logging/LogUtil', (): any => {
const logger: jest.Mocked<Logger> = getLoggerFor('GuardedStream') as any;

class DummyConverter extends TypedRepresentationConverter {
public constructor() {
super('*/*', 'custom/type');
}

public async getInputTypes(): Promise<Record<string, number>> {
return { '*/*': 1 };
}
Expand Down

0 comments on commit 87a5401

Please sign in to comment.