From 87a54011b48399df71cde46bc3f7d8d443795858 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Mon, 26 Apr 2021 14:28:17 +0200 Subject: [PATCH] refactor: Extend and update the ConversionUtil functions --- .../RepresentationPreferences.ts | 6 + src/storage/conversion/ConstantConverter.ts | 4 +- src/storage/conversion/ContentTypeReplacer.ts | 8 +- src/storage/conversion/ConversionUtil.ts | 172 +++++++++++------- src/storage/conversion/IfNeededConverter.ts | 4 +- src/storage/conversion/QuadToRdfConverter.ts | 5 +- .../TypedRepresentationConverter.ts | 23 ++- test/integration/GuardedStream.test.ts | 4 + .../storage/conversion/ConversionUtil.test.ts | 143 +++++++++------ .../TypedRepresentationConverter.test.ts | 29 +++ 10 files changed, 262 insertions(+), 136 deletions(-) diff --git a/src/ldp/representation/RepresentationPreferences.ts b/src/ldp/representation/RepresentationPreferences.ts index b11c1c4eb6..c1adcd5e44 100644 --- a/src/ldp/representation/RepresentationPreferences.ts +++ b/src/ldp/representation/RepresentationPreferences.ts @@ -9,6 +9,12 @@ */ export type ValuePreferences = Record; +/** + * 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. * diff --git a/src/storage/conversion/ConstantConverter.ts b/src/storage/conversion/ConstantConverter.ts index 29010a2847..501fbcde84 100644 --- a/src/storage/conversion/ConstantConverter.ts +++ b/src/storage/conversion/ConstantConverter.ts @@ -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'; @@ -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}`); } diff --git a/src/storage/conversion/ContentTypeReplacer.ts b/src/storage/conversion/ContentTypeReplacer.ts index 1783bbaa18..49bb625517 100644 --- a/src/storage/conversion/ContentTypeReplacer.ts +++ b/src/storage/conversion/ContentTypeReplacer.ts @@ -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'; @@ -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; } } diff --git a/src/storage/conversion/ConversionUtil.ts b/src/storage/conversion/ConversionUtil.ts index 0c8faa2648..45953995d5 100644 --- a/src/storage/conversion/ConversionUtil.ts +++ b/src/storage/conversion/ConversionUtil.ts @@ -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; } /** @@ -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)}.`, - ); - } -} diff --git a/src/storage/conversion/IfNeededConverter.ts b/src/storage/conversion/IfNeededConverter.ts index 6586504c69..55d4db8d64 100644 --- a/src/storage/conversion/IfNeededConverter.ts +++ b/src/storage/conversion/IfNeededConverter.ts @@ -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'; @@ -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 ? diff --git a/src/storage/conversion/QuadToRdfConverter.ts b/src/storage/conversion/QuadToRdfConverter.ts index 757b28c2d4..00e7703618 100644 --- a/src/storage/conversion/QuadToRdfConverter.ts +++ b/src/storage/conversion/QuadToRdfConverter.ts @@ -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'; @@ -26,7 +26,8 @@ export class QuadToRdfConverter extends TypedRepresentationConverter { public async handle({ identifier, representation: quads, preferences }: RepresentationConverterArgs): Promise { - 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) diff --git a/src/storage/conversion/TypedRepresentationConverter.ts b/src/storage/conversion/TypedRepresentationConverter.ts index 3b54a8012d..82b6e7e006 100644 --- a/src/storage/conversion/TypedRepresentationConverter.ts +++ b/src/storage/conversion/TypedRepresentationConverter.ts @@ -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'; @@ -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 { 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)}.`, + ); + } } } diff --git a/test/integration/GuardedStream.test.ts b/test/integration/GuardedStream.test.ts index ed40e7ea82..7db997e260 100644 --- a/test/integration/GuardedStream.test.ts +++ b/test/integration/GuardedStream.test.ts @@ -17,6 +17,10 @@ jest.mock('../../src/logging/LogUtil', (): any => { const logger: jest.Mocked = getLoggerFor('GuardedStream') as any; class DummyConverter extends TypedRepresentationConverter { + public constructor() { + super('*/*', 'custom/type'); + } + public async getInputTypes(): Promise> { return { '*/*': 1 }; } diff --git a/test/unit/storage/conversion/ConversionUtil.test.ts b/test/unit/storage/conversion/ConversionUtil.test.ts index f98fe09034..379c99297c 100644 --- a/test/unit/storage/conversion/ConversionUtil.test.ts +++ b/test/unit/storage/conversion/ConversionUtil.test.ts @@ -1,96 +1,133 @@ import type { ValuePreferences } from '../../../../src/ldp/representation/RepresentationPreferences'; import { - hasMatchingMediaTypes, + cleanPreferences, + getBestPreference, + getConversionTarget, + getTypeWeight, + getWeightedPreferences, + matchesMediaPreferences, matchesMediaType, - matchingMediaTypes, - supportsMediaTypeConversion, } from '../../../../src/storage/conversion/ConversionUtil'; import { InternalServerError } from '../../../../src/util/errors/InternalServerError'; describe('ConversionUtil', (): void => { - describe('#supportsMediaTypeConversion', (): void => { - it('requires preferences.', async(): Promise => { - expect((): any => supportsMediaTypeConversion()).toThrow(); + describe('#cleanPreferences', (): void => { + it('supports all types for empty preferences.', async(): Promise => { + expect(cleanPreferences()).toEqual({ '*/*': 1, 'internal/*': 0 }); + expect(cleanPreferences({})).toEqual(expect.objectContaining({ '*/*': 1, 'internal/*': 0 })); }); - it('requires an input type.', async(): Promise => { - expect((): any => supportsMediaTypeConversion(undefined, { 'b/x': 1 }, { 'a/x': 1 }, { 'a/x': 1 })) - .toThrow('Cannot convert from unknown to b/x, only from a/x to a/x.'); + it('filters out internal types.', async(): Promise => { + const preferences: ValuePreferences = { 'a/a': 1 }; + expect(cleanPreferences(preferences)).toEqual({ 'a/a': 1, 'internal/*': 0 }); + }); + + it('keeps internal types that are specifically requested.', async(): Promise => { + const preferences: ValuePreferences = { 'a/a': 1, 'internal/*': 0.5 }; + expect(cleanPreferences(preferences)).toEqual({ 'a/a': 1, 'internal/*': 0.5 }); + }); + }); + + describe('#getTypeWeight', (): void => { + it('returns the matching weight from the preferences.', async(): Promise => { + const preferences: ValuePreferences = { 'a/a': 0.8 }; + expect(getTypeWeight('a/a', preferences)).toBe(0.8); }); - it('requires a matching input type.', async(): Promise => { - expect((): any => supportsMediaTypeConversion('a/x', { 'b/x': 1 }, { 'c/x': 1 }, { 'a/x': 1 })) - .toThrow('Cannot convert from a/x to b/x, only from c/x to a/x.'); + it('returns the most specific weight.', async(): Promise => { + const preferences: ValuePreferences = { 'a/*': 0.5, '*/*': 0.8 }; + expect(getTypeWeight('a/a', preferences)).toBe(0.5); }); - it('requires a matching output type.', async(): Promise => { - expect((): any => supportsMediaTypeConversion('a/x', { 'b/x': 1 }, { 'a/x': 1 }, { 'c/x': 1 })) - .toThrow('Cannot convert from a/x to b/x, only from a/x to c/x.'); + it('returns 0 if no match is possible.', async(): Promise => { + const preferences: ValuePreferences = { 'b/*': 0.5, 'c/c': 0.8 }; + expect(getTypeWeight('a/a', preferences)).toBe(0); }); - it('succeeds with a valid input and output type.', async(): Promise => { - expect(supportsMediaTypeConversion('a/x', { 'b/x': 1 }, { 'a/x': 1 }, { 'b/x': 1 })) - .toBeUndefined(); + it('errors on invalid types.', async(): Promise => { + expect((): any => getTypeWeight('unknown', {})).toThrow(InternalServerError); + expect((): any => getTypeWeight('unknown', {})).toThrow('Unexpected media type: unknown.'); }); }); - describe('#matchingMediaTypes', (): void => { - it('returns the empty array if no preferences specified.', async(): Promise => { - expect(matchingMediaTypes()) - .toEqual([]); + describe('#getWeightedPreferences', (): void => { + it('returns all weights in a sorted list.', async(): Promise => { + const types: ValuePreferences = { 'a/a': 0.5, 'b/b': 1, 'c/c': 0.8 }; + const preferences: ValuePreferences = { 'a/*': 1, 'c/c': 0.8 }; + expect(getWeightedPreferences(types, preferences)).toEqual([ + { value: 'c/c', weight: 0.8 * 0.8 }, + { value: 'a/a', weight: 0.5 }, + { value: 'b/b', weight: 0 }, + ]); }); + }); - it('returns matching types if weight > 0.', async(): Promise => { - const preferences: ValuePreferences = { 'a/x': 1, 'b/x': 0.5, 'c/x': 0 }; - expect(matchingMediaTypes(preferences, { 'b/x': 1, 'c/x': 1 })) - .toEqual([ 'b/x' ]); + describe('#getBestPreference', (): void => { + it('returns the best match.', async(): Promise => { + const types: ValuePreferences = { 'a/a': 0.5, 'b/b': 1, 'c/c': 0.8 }; + const preferences: ValuePreferences = { 'a/*': 1, 'c/c': 0.8 }; + expect(getBestPreference(types, preferences)).toEqual({ value: 'c/c', weight: 0.8 * 0.8 }); }); - it('sorts by descending weight.', async(): Promise => { - const preferences: ValuePreferences = { 'a/x': 1, 'b/x': 0.5, 'c/x': 0.8 }; - expect(matchingMediaTypes(preferences, { 'a/x': 1, 'b/x': 1, 'c/x': 1 })) - .toEqual([ 'a/x', 'c/x', 'b/x' ]); + it('returns undefined if there is no match.', async(): Promise => { + const types: ValuePreferences = { 'a/a': 0.5, 'b/b': 1, 'c/c': 0.8 }; + const preferences: ValuePreferences = { 'd/*': 1, 'e/e': 0.8 }; + expect(getBestPreference(types, preferences)).toBeUndefined(); }); + }); - it('incorporates representation qualities when calculating weight.', async(): Promise => { - const preferences: ValuePreferences = { 'a/x': 1, 'b/x': 0.5, 'c/x': 0.8 }; - expect(matchingMediaTypes(preferences, { 'a/x': 0.1, 'b/x': 1, 'c/x': 0.6 })) - .toEqual([ 'b/x', 'c/x', 'a/x' ]); + describe('#getConversionTarget', (): void => { + it('returns the best match.', async(): Promise => { + const types: ValuePreferences = { 'a/a': 0.5, 'b/b': 1, 'c/c': 0.8 }; + const preferences: ValuePreferences = { 'a/*': 1, 'c/c': 0.8 }; + expect(getConversionTarget(types, preferences)).toBe('c/c'); }); - it('errors if there invalid types.', async(): Promise => { - const preferences: ValuePreferences = { 'b/x': 1 }; - expect((): any => matchingMediaTypes(preferences, { noType: 1 })).toThrow(InternalServerError); - expect((): any => matchingMediaTypes(preferences, { noType: 1 })).toThrow('Unexpected type preference: noType'); + it('matches anything if there are no preferences.', async(): Promise => { + const types: ValuePreferences = { 'a/a': 0.5, 'b/b': 1, 'c/c': 0.8 }; + expect(getConversionTarget(types)).toBe('b/b'); }); - it('filters out internal types.', async(): Promise => { - const preferences: ValuePreferences = { '*/*': 1 }; - expect(matchingMediaTypes(preferences, { 'a/x': 1, 'internal/quads': 1 })) - .toEqual([ 'a/x' ]); + it('returns undefined if there is no match.', async(): Promise => { + const types: ValuePreferences = { 'a/a': 0.5, 'b/b': 1, 'c/c': 0.8 }; + const preferences: ValuePreferences = { 'd/*': 1, 'e/e': 0.8 }; + expect(getConversionTarget(types, preferences)).toBeUndefined(); }); - it('keeps internal types that are specifically requested.', async(): Promise => { - const preferences: ValuePreferences = { '*/*': 1, 'internal/*': 0.5 }; - expect(matchingMediaTypes(preferences, { 'a/x': 1, 'internal/quads': 1 })) - .toEqual([ 'a/x', 'internal/quads' ]); + it('does not match internal types if not in the preferences.', async(): Promise => { + const types: ValuePreferences = { 'a/a': 0.5, 'internal/b': 1, 'c/c': 0.8 }; + expect(getConversionTarget(types)).toBe('c/c'); }); - it('takes the most relevant weight for a type.', async(): Promise => { - const preferences: ValuePreferences = { '*/*': 1, 'internal/quads': 0.5 }; - expect(matchingMediaTypes(preferences, { 'a/x': 1, 'internal/quads': 1 })) - .toEqual([ 'a/x', 'internal/quads' ]); + it('matches internal types if they are specifically requested.', async(): Promise => { + const types: ValuePreferences = { 'a/a': 0.5, 'internal/b': 1, 'c/c': 0.8 }; + const preferences: ValuePreferences = { 'a/*': 1, 'internal/b': 1, 'c/c': 0.8 }; + expect(getConversionTarget(types, preferences)).toBe('internal/b'); }); }); - describe('#hasMatchingMediatypes', (): void => { + describe('#matchesMediaPreferences', (): void => { it('returns false if there are no matches.', async(): Promise => { - expect(hasMatchingMediaTypes()).toEqual(false); + const preferences: ValuePreferences = { 'a/x': 1, 'b/x': 0.5, 'c/x': 0 }; + expect(matchesMediaPreferences('c/x', preferences)).toEqual(false); }); it('returns true if there are matches.', async(): Promise => { const preferences: ValuePreferences = { 'a/x': 1, 'b/x': 0.5, 'c/x': 0 }; - expect(hasMatchingMediaTypes(preferences, { 'b/x': 1, 'c/x': 1 })).toEqual(true); + expect(matchesMediaPreferences('b/x', preferences)).toEqual(true); + }); + + it('matches anything if there are no preferences.', async(): Promise => { + expect(matchesMediaPreferences('a/a')).toEqual(true); + }); + + it('does not match internal types if not in the preferences.', async(): Promise => { + expect(matchesMediaPreferences('internal/b')).toBe(false); + }); + + it('matches internal types if they are specifically requested.', async(): Promise => { + const preferences: ValuePreferences = { 'a/*': 1, 'internal/b': 1, 'c/c': 0.8 }; + expect(matchesMediaPreferences('internal/b', preferences)).toBe(true); }); }); diff --git a/test/unit/storage/conversion/TypedRepresentationConverter.test.ts b/test/unit/storage/conversion/TypedRepresentationConverter.test.ts index cf648463ad..f9d5561a34 100644 --- a/test/unit/storage/conversion/TypedRepresentationConverter.test.ts +++ b/test/unit/storage/conversion/TypedRepresentationConverter.test.ts @@ -1,4 +1,6 @@ +import type { RepresentationConverterArgs } from '../../../../src/storage/conversion/RepresentationConverter'; import { TypedRepresentationConverter } from '../../../../src/storage/conversion/TypedRepresentationConverter'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; class CustomTypedRepresentationConverter extends TypedRepresentationConverter { public handle = jest.fn(); @@ -44,4 +46,31 @@ describe('A TypedRepresentationConverter', (): void => { 'c/d': 0.5, }); }); + + it('can not handle input without a Content-Type.', async(): Promise => { + const args: RepresentationConverterArgs = { representation: { metadata: { }}, preferences: {}} as any; + const converter = new CustomTypedRepresentationConverter('*/*'); + await expect(converter.canHandle(args)).rejects.toThrow(NotImplementedHttpError); + }); + + it('can not handle a type that does not match the input types.', async(): Promise => { + const args: RepresentationConverterArgs = + { representation: { metadata: { contentType: 'b/b' }}, preferences: {}} as any; + const converter = new CustomTypedRepresentationConverter('a/a'); + await expect(converter.canHandle(args)).rejects.toThrow(NotImplementedHttpError); + }); + + it('can not handle preference that do not match the output types.', async(): Promise => { + const args: RepresentationConverterArgs = + { representation: { metadata: { contentType: 'a/a' }}, preferences: { type: { 'c/c': 1 }}} as any; + const converter = new CustomTypedRepresentationConverter('a/a', { 'c/*': 0, 'd/d': 1 }); + await expect(converter.canHandle(args)).rejects.toThrow(NotImplementedHttpError); + }); + + it('can handle input where the type and preferences match the converter.', async(): Promise => { + const args: RepresentationConverterArgs = + { representation: { metadata: { contentType: 'a/a' }}, preferences: { type: { 'c/*': 1 }}} as any; + const converter = new CustomTypedRepresentationConverter('a/a', { 'c/c': 1, 'd/d': 1 }); + await expect(converter.canHandle(args)).resolves.toBeUndefined(); + }); });