feat: Reject unacceptable content types

This commit is contained in:
Joachim Van Herwegen 2020-11-09 11:31:09 +01:00
parent c1aa25f314
commit 69ed2e069f
5 changed files with 103 additions and 32 deletions

View File

@ -7,7 +7,12 @@ export interface RepresentationPreference {
*/ */
value: string; value: string;
/** /**
* How important this preference is in a value going from 0 to 1. * How preferred this value is in a number going from 0 to 1.
* Follows the quality values rule from RFC 7231:
*
* "The weight is normalized to a real number in the range 0 through 1,
* where 0.001 is the least preferred and 1 is the most preferred; a
* value of 0 means "not acceptable"."
*/ */
weight: number; weight: number;
} }

View File

@ -2,9 +2,10 @@ import type { Representation } from '../ldp/representation/Representation';
import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences'; import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences';
import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
import { getLoggerFor } from '../logging/LogUtil'; import { getLoggerFor } from '../logging/LogUtil';
import { matchingMediaType } from '../util/Util'; import { InternalServerError } from '../util/errors/InternalServerError';
import type { Conditions } from './Conditions'; import type { Conditions } from './Conditions';
import type { RepresentationConverter } from './conversion/RepresentationConverter'; import { matchingTypes } from './conversion/ConversionUtil';
import type { RepresentationConverter, RepresentationConverterArgs } from './conversion/RepresentationConverter';
import { PassthroughStore } from './PassthroughStore'; import { PassthroughStore } from './PassthroughStore';
import type { ResourceStore } from './ResourceStore'; import type { ResourceStore } from './ResourceStore';
@ -48,11 +49,7 @@ export class RepresentationConvertingStore<T extends ResourceStore = ResourceSto
public async getRepresentation(identifier: ResourceIdentifier, preferences: RepresentationPreferences, public async getRepresentation(identifier: ResourceIdentifier, preferences: RepresentationPreferences,
conditions?: Conditions): Promise<Representation> { conditions?: Conditions): Promise<Representation> {
const representation = await super.getRepresentation(identifier, preferences, conditions); const representation = await super.getRepresentation(identifier, preferences, conditions);
if (!this.outConverter || this.matchesPreferences(representation, preferences)) { return this.convertRepresentation({ identifier, representation, preferences }, this.outConverter);
return representation;
}
this.logger.info(`Convert ${identifier.path} from ${representation.metadata.contentType} to ${preferences.type}`);
return this.outConverter.handleSafe({ identifier, representation, preferences });
} }
public async addResource(container: ResourceIdentifier, representation: Representation, public async addResource(container: ResourceIdentifier, representation: Representation,
@ -69,28 +66,46 @@ export class RepresentationConvertingStore<T extends ResourceStore = ResourceSto
return this.source.setRepresentation(identifier, representation, conditions); return this.source.setRepresentation(identifier, representation, conditions);
} }
/**
* Helper function that checks if the given representation matches the given preferences.
*/
private matchesPreferences(representation: Representation, preferences: RepresentationPreferences): boolean { private matchesPreferences(representation: Representation, preferences: RepresentationPreferences): boolean {
if (!preferences.type) {
return true;
}
const { contentType } = representation.metadata; const { contentType } = representation.metadata;
return Boolean(
contentType && if (!contentType) {
preferences.type.some((type): boolean => throw new InternalServerError('Content-Type is required for data conversion.');
type.weight > 0 && }
matchingMediaType(type.value, contentType)),
); // Check if there is a result if we try to map the preferences to the content-type
return matchingTypes(preferences, [ contentType ]).length > 0;
} }
/**
* Helper function that converts a Representation using the given args and converter,
* if the conversion is necessary and there is a converter.
*/
private async convertRepresentation(args: RepresentationConverterArgs, converter?: RepresentationConverter):
Promise<Representation> {
if (!converter || !args.preferences.type || this.matchesPreferences(args.representation, args.preferences)) {
return args.representation;
}
const typeStr = args.preferences.type.map((pref): string => `${pref.value};q=${pref.weight}`).join(', ');
this.logger.info(`Convert ${args.identifier.path} from ${args.representation.metadata.contentType} to ${typeStr}`);
return converter.handleSafe(args);
}
/**
* Helper function that converts an incoming representation if necessary.
*/
private async convertInRepresentation(identifier: ResourceIdentifier, representation: Representation): private async convertInRepresentation(identifier: ResourceIdentifier, representation: Representation):
Promise<Representation> { Promise<Representation> {
if (!this.inType) { if (!this.inType) {
return representation; return representation;
} }
const inPreferences: RepresentationPreferences = { type: [{ value: this.inType, weight: 1 }]}; const preferences: RepresentationPreferences = { type: [{ value: this.inType, weight: 1 }]};
if (!inPreferences || !this.inConverter || this.matchesPreferences(representation, inPreferences)) {
return representation; return this.convertRepresentation({ identifier, representation, preferences }, this.inConverter);
}
return this.inConverter.handleSafe({ identifier, representation, preferences: inPreferences });
} }
} }

View File

@ -1,26 +1,51 @@
import type { RepresentationPreference } from '../../ldp/representation/RepresentationPreference'; import type { RepresentationPreference } from '../../ldp/representation/RepresentationPreference';
import type { RepresentationPreferences } from '../../ldp/representation/RepresentationPreferences'; import type { RepresentationPreferences } from '../../ldp/representation/RepresentationPreferences';
import { InternalServerError } from '../../util/errors/InternalServerError';
import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError'; import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError';
import { matchingMediaType } from '../../util/Util'; import { matchingMediaType } from '../../util/Util';
import type { RepresentationConverterArgs } from './RepresentationConverter'; import type { RepresentationConverterArgs } from './RepresentationConverter';
/** /**
* Filters out the media types from the preferred types that correspond to one of the supported types. * Filters media types based on the given preferences.
* Based on RFC 7231 - Content negotiation.
*
* @param preferences - Preferences for output type. * @param preferences - Preferences for output type.
* @param supported - Types supported by the parser. * @param types - Media types to compare to the preferences.
* *
* @throws UnsupportedHttpError * @throws UnsupportedHttpError
* If the type preferences are undefined. * If the type preferences are undefined or if there are duplicate preferences.
* *
* @returns The filtered list of preferences. * @returns The weighted and filtered list of matching types.
*/ */
export const matchingTypes = (preferences: RepresentationPreferences, supported: string[]): export const matchingTypes = (preferences: RepresentationPreferences, types: string[]):
RepresentationPreference[] => { RepresentationPreference[] => {
if (!Array.isArray(preferences.type)) { if (!Array.isArray(preferences.type)) {
throw new UnsupportedHttpError('Output type required for conversion.'); throw new UnsupportedHttpError('Output type required for conversion.');
} }
return preferences.type.filter(({ value, weight }): boolean => weight > 0 &&
supported.some((type): boolean => matchingMediaType(value, type))); const prefMap = preferences.type.reduce((map: Record<string, number>, pref): Record<string, number> => {
if (map[pref.value]) {
throw new UnsupportedHttpError(`Duplicate type preference found: ${pref.value}`);
}
map[pref.value] = pref.weight;
return map;
}, {});
// 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 = types.map((type): RepresentationPreference => {
const match = /^([^/]+)\/([^\s;]+)/u.exec(type);
if (!match) {
throw new InternalServerError(`Unexpected type preference: ${type}`);
}
const [ , main, sub ] = match;
const weight = prefMap[type] ?? prefMap[`${main}/${sub}`] ?? prefMap[`${main}/*`] ?? prefMap['*/*'] ?? 0;
return { value: type, weight };
});
return weightedSupported.filter((preference): boolean => preference.weight !== 0);
}; };
/** /**

View File

@ -3,6 +3,7 @@ import { RepresentationMetadata } from '../../../src/ldp/representation/Represen
import type { RepresentationConverter } from '../../../src/storage/conversion/RepresentationConverter'; import type { RepresentationConverter } from '../../../src/storage/conversion/RepresentationConverter';
import { RepresentationConvertingStore } from '../../../src/storage/RepresentationConvertingStore'; import { RepresentationConvertingStore } from '../../../src/storage/RepresentationConvertingStore';
import type { ResourceStore } from '../../../src/storage/ResourceStore'; import type { ResourceStore } from '../../../src/storage/ResourceStore';
import { InternalServerError } from '../../../src/util/errors/InternalServerError';
import { CONTENT_TYPE } from '../../../src/util/UriConstants'; import { CONTENT_TYPE } from '../../../src/util/UriConstants';
describe('A RepresentationConvertingStore', (): void => { describe('A RepresentationConvertingStore', (): void => {
@ -30,7 +31,7 @@ describe('A RepresentationConvertingStore', (): void => {
it('returns the Representation from the source if no changes are required.', async(): Promise<void> => { it('returns the Representation from the source if no changes are required.', async(): Promise<void> => {
const result = await store.getRepresentation({ path: 'path' }, { type: [ const result = await store.getRepresentation({ path: 'path' }, { type: [
{ value: 'text/*', weight: 0 }, { value: 'text/turtle', weight: 1 }, { value: 'application/*', weight: 0 }, { value: 'text/turtle', weight: 1 },
]}); ]});
expect(result).toEqual({ expect(result).toEqual({
data: 'data', data: 'data',
@ -39,7 +40,9 @@ describe('A RepresentationConvertingStore', (): void => {
expect(result.metadata.contentType).toEqual('text/turtle'); expect(result.metadata.contentType).toEqual('text/turtle');
expect(source.getRepresentation).toHaveBeenCalledTimes(1); expect(source.getRepresentation).toHaveBeenCalledTimes(1);
expect(source.getRepresentation).toHaveBeenLastCalledWith( expect(source.getRepresentation).toHaveBeenLastCalledWith(
{ path: 'path' }, { type: [{ value: 'text/*', weight: 0 }, { value: 'text/turtle', weight: 1 }]}, undefined, { path: 'path' },
{ type: [{ value: 'application/*', weight: 0 }, { value: 'text/turtle', weight: 1 }]},
undefined,
); );
expect(outConverter.handleSafe).toHaveBeenCalledTimes(0); expect(outConverter.handleSafe).toHaveBeenCalledTimes(0);
}); });
@ -98,4 +101,11 @@ describe('A RepresentationConvertingStore', (): void => {
expect(inConverter.handleSafe).toHaveBeenCalledTimes(2); expect(inConverter.handleSafe).toHaveBeenCalledTimes(2);
expect(source.setRepresentation).toHaveBeenLastCalledWith(id, 'inConvert', 'conditions'); expect(source.setRepresentation).toHaveBeenLastCalledWith(id, 'inConvert', 'conditions');
}); });
it('throws an error if no content-type is provided.', async(): Promise<void> => {
metadata.removeAll(CONTENT_TYPE);
const id = { path: 'identifier' };
await expect(store.addResource(id, representation, 'conditions' as any)).rejects.toThrow(InternalServerError);
});
}); });

View File

@ -3,6 +3,8 @@ import { RepresentationMetadata } from '../../../../src/ldp/representation/Repre
import type { RepresentationPreferences } from '../../../../src/ldp/representation/RepresentationPreferences'; import type { RepresentationPreferences } from '../../../../src/ldp/representation/RepresentationPreferences';
import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
import { checkRequest, matchingTypes } from '../../../../src/storage/conversion/ConversionUtil'; import { checkRequest, matchingTypes } from '../../../../src/storage/conversion/ConversionUtil';
import { InternalServerError } from '../../../../src/util/errors/InternalServerError';
import { UnsupportedHttpError } from '../../../../src/util/errors/UnsupportedHttpError';
describe('A ConversionUtil', (): void => { describe('A ConversionUtil', (): void => {
const identifier: ResourceIdentifier = { path: 'path' }; const identifier: ResourceIdentifier = { path: 'path' };
@ -38,7 +40,7 @@ describe('A ConversionUtil', (): void => {
it('succeeds with a valid input and output type.', async(): Promise<void> => { it('succeeds with a valid input and output type.', async(): Promise<void> => {
metadata.contentType = 'a/x'; metadata.contentType = 'a/x';
const preferences: RepresentationPreferences = { type: [{ value: 'b/x', weight: 1 }]}; const preferences: RepresentationPreferences = { type: [{ value: 'b/x', weight: 1 }]};
expect(checkRequest({ identifier, representation, preferences }, [ '*/*' ], [ '*/*' ])) expect(checkRequest({ identifier, representation, preferences }, [ 'a/x' ], [ 'b/x' ]))
.toBeUndefined(); .toBeUndefined();
}); });
}); });
@ -55,5 +57,19 @@ describe('A ConversionUtil', (): void => {
[{ value: 'a/x', weight: 1 }, { value: 'b/x', weight: 0.5 }, { value: 'c/x', weight: 0 }]}; [{ value: 'a/x', weight: 1 }, { value: 'b/x', weight: 0.5 }, { value: 'c/x', weight: 0 }]};
expect(matchingTypes(preferences, [ 'b/x', 'c/x' ])).toEqual([{ value: 'b/x', weight: 0.5 }]); expect(matchingTypes(preferences, [ 'b/x', 'c/x' ])).toEqual([{ value: 'b/x', weight: 0.5 }]);
}); });
it('errors if there are duplicate preferences.', async(): Promise<void> => {
const preferences: RepresentationPreferences =
{ type: [{ value: 'b/x', weight: 1 }, { value: 'b/x', weight: 0 }]};
expect((): any => matchingTypes(preferences, [ 'b/x' ]))
.toThrow(new UnsupportedHttpError(`Duplicate type preference found: b/x`));
});
it('errors if there invalid types.', async(): Promise<void> => {
const preferences: RepresentationPreferences =
{ type: [{ value: 'b/x', weight: 1 }]};
expect((): any => matchingTypes(preferences, [ 'noType' ]))
.toThrow(new InternalServerError(`Unexpected type preference: noType`));
});
}); });
}); });