feat: Create MetadataHandler

This commit is contained in:
Joachim Van Herwegen 2020-10-06 16:12:37 +02:00
parent bb28af937b
commit 7dcb3eaa84
14 changed files with 355 additions and 41 deletions

View File

@ -109,7 +109,7 @@ export * from './src/util/errors/UnsupportedHttpError';
export * from './src/util/errors/UnsupportedMediaTypeHttpError';
// Util
export * from './src/util/AcceptParser';
export * from './src/util/HeaderUtil';
export * from './src/util/AsyncHandler';
export * from './src/util/CompositeAsyncHandler';
export * from './src/util/InteractionController';

View File

@ -1,11 +1,11 @@
import type { HttpRequest } from '../../server/HttpRequest';
import type { AcceptHeader } from '../../util/AcceptParser';
import type { AcceptHeader } from '../../util/HeaderUtil';
import {
parseAccept,
parseAcceptCharset,
parseAcceptEncoding,
parseAcceptLanguage,
} from '../../util/AcceptParser';
} from '../../util/HeaderUtil';
import type { RepresentationPreference } from '../representation/RepresentationPreference';
import type { RepresentationPreferences } from '../representation/RepresentationPreferences';
import { PreferenceParser } from './PreferenceParser';

View File

@ -0,0 +1,29 @@
import type { HttpRequest } from '../../../server/HttpRequest';
import { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { MetadataExtractor } from './MetadataExtractor';
import type { MetadataParser } from './MetadataParser';
/**
* MetadataExtractor that lets each of its MetadataParsers add metadata based on the HttpRequest.
*/
export class BasicMetadataExtractor extends MetadataExtractor {
private readonly parsers: MetadataParser[];
public constructor(parsers: MetadataParser[]) {
super();
this.parsers = parsers;
}
public async canHandle(): Promise<void> {
// Can handle all requests
}
public async handle(request: HttpRequest):
Promise<RepresentationMetadata> {
const metadata = new RepresentationMetadata();
for (const parser of this.parsers) {
await parser.parse(request, metadata);
}
return metadata;
}
}

View File

@ -0,0 +1,17 @@
import type { HttpRequest } from '../../../server/HttpRequest';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import type { MetadataParser } from './MetadataParser';
/**
* Parser for the `content-type` header.
* Currently only stores the media type and ignores other parameters such as charset.
*/
export class ContentTypeParser implements MetadataParser {
public async parse(request: HttpRequest, metadata: RepresentationMetadata): Promise<void> {
const contentType = request.headers['content-type'];
if (contentType) {
// Will need to use HeaderUtil once parameters need to be parsed
metadata.contentType = /^[^;]*/u.exec(contentType)![0].trim();
}
}
}

View File

@ -0,0 +1,38 @@
import { DataFactory } from 'n3';
import { getLoggerFor } from '../../../logging/LogUtil';
import type { HttpRequest } from '../../../server/HttpRequest';
import { parseParameters, splitAndClean, transformQuotedStrings } from '../../../util/HeaderUtil';
import { RDF } from '../../../util/UriConstants';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import type { MetadataParser } from './MetadataParser';
/**
* Parses Link headers with "rel=type" parameters and adds them as RDF.type metadata.
*/
export class LinkTypeParser implements MetadataParser {
protected readonly logger = getLoggerFor(this);
public async parse(request: HttpRequest, metadata: RepresentationMetadata): Promise<void> {
const link = request.headers.link ?? [];
const entries: string[] = Array.isArray(link) ? link : [ link ];
for (const entry of entries) {
this.parseLink(entry, metadata);
}
}
protected parseLink(linkEntry: string, metadata: RepresentationMetadata): void {
const { result, replacements } = transformQuotedStrings(linkEntry);
for (const part of splitAndClean(result)) {
const [ link, ...parameters ] = part.split(/\s*;\s*/u);
if (/^[^<]|[^>]$/u.test(link)) {
this.logger.warn(`Invalid link header ${part}.`);
continue;
}
for (const { name, value } of parseParameters(parameters, replacements)) {
if (name === 'rel' && value === 'type') {
metadata.add(RDF.type, DataFactory.namedNode(link.slice(1, -1)));
}
}
}
}
}

View File

@ -0,0 +1,9 @@
import type { HttpRequest } from '../../../server/HttpRequest';
import { AsyncHandler } from '../../../util/AsyncHandler';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
/**
* Parses the metadata of a {@link HttpRequest} into a {@link RepresentationMetadata}.
*/
export abstract class MetadataExtractor extends
AsyncHandler<HttpRequest, RepresentationMetadata> {}

View File

@ -0,0 +1,15 @@
import type { HttpRequest } from '../../../server/HttpRequest';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
/**
* A parser that takes a specific part of an HttpRequest and converts it to medata,
* such as the value of a header entry.
*/
export interface MetadataParser {
/**
* Potentially adds metadata to the RepresentationMetadata based on the HttpRequest contents.
* @param request - Request with potential metadata.
* @param metadata - Metadata objects that should be updated.
*/
parse: (request: HttpRequest, metadata: RepresentationMetadata) => Promise<void>;
}

View File

@ -0,0 +1,20 @@
import type { HttpRequest } from '../../../server/HttpRequest';
import { UnsupportedHttpError } from '../../../util/errors/UnsupportedHttpError';
import { HTTP } from '../../../util/UriConstants';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import type { MetadataParser } from './MetadataParser';
/**
* Converts the contents of the slug header to metadata.
*/
export class SlugParser implements MetadataParser {
public async parse(request: HttpRequest, metadata: RepresentationMetadata): Promise<void> {
const { slug } = request.headers;
if (slug) {
if (Array.isArray(slug)) {
throw new UnsupportedHttpError('At most 1 slug header is allowed.');
}
metadata.set(HTTP.slug, slug);
}
}
}

View File

@ -99,18 +99,18 @@ const token = /^[a-zA-Z0-9!#$%&'*+-.^_`|~]+$/u;
*
* @returns The transformed string and a map with keys `"0"`, etc. and values the original string that was there.
*/
const transformQuotedStrings = (input: string): { result: string; replacements: { [id: string]: string } } => {
export const transformQuotedStrings = (input: string): { result: string; replacements: { [id: string]: string } } => {
let idx = 0;
const replacements: { [id: string]: string } = {};
const result = input.replace(/"(?:[^"\\]|\\.)*"/gu, (match): string => {
// Not all characters allowed in quoted strings, see BNF above
if (!/^"(?:[\t !\u0023-\u005B\u005D-\u007E\u0080-\u00FF]|(?:\\[\t\u0020-\u007E\u0080-\u00FF]))*"$/u.test(match)) {
throw new UnsupportedHttpError(
`Invalid quoted string in Accept header: ${match}. Check which characters are allowed`,
`Invalid quoted string in header: ${match}. Check which characters are allowed`,
);
}
const replacement = `"${idx}"`;
replacements[replacement] = match;
replacements[replacement] = match.slice(1, -1);
idx += 1;
return replacement;
});
@ -122,7 +122,7 @@ const transformQuotedStrings = (input: string): { result: string; replacements:
*
* @param input - Input header string.
*/
const splitAndClean = (input: string): string[] =>
export const splitAndClean = (input: string): string[] =>
input.split(',')
.map((part): string => part.trim())
.filter((part): boolean => part.length > 0);
@ -136,13 +136,47 @@ const splitAndClean = (input: string): string[] =>
* Thrown on invalid syntax.
*/
const testQValue = (qvalue: string): void => {
if (!/^q=(?:(?:0(?:\.\d{0,3})?)|(?:1(?:\.0{0,3})?))$/u.test(qvalue)) {
if (!/^(?:(?:0(?:\.\d{0,3})?)|(?:1(?:\.0{0,3})?))$/u.test(qvalue)) {
throw new UnsupportedHttpError(
`Invalid q value: ${qvalue} does not match ("q=" ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3("0") ] )).`,
`Invalid q value: ${qvalue} does not match ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3("0") ] ).`,
);
}
};
/**
* Parses a list of split parameters and checks their validity.
*
* @param parameters - A list of split parameters (token [ "=" ( token / quoted-string ) ])
* @param replacements - The double quoted strings that need to be replaced.
*
*
* @throws {@link UnsupportedHttpError}
* Thrown on invalid parameter syntax.
*
* @returns An array of name/value objects corresponding to the parameters.
*/
export const parseParameters = (parameters: string[], replacements: { [id: string]: string }):
{ name: string; value: string }[] => parameters.map((param): { name: string; value: string } => {
const [ name, rawValue ] = param.split('=').map((str): string => str.trim());
// Test replaced string for easier check
// parameter = token "=" ( token / quoted-string )
// second part is optional for certain parameters
if (!(token.test(name) && (!rawValue || /^"\d+"$/u.test(rawValue) || token.test(rawValue)))) {
throw new UnsupportedHttpError(
`Invalid parameter value: ${name}=${replacements[rawValue] || rawValue} ` +
`does not match (token ( "=" ( token / quoted-string ))?). `,
);
}
let value = rawValue;
if (value in replacements) {
value = replacements[rawValue];
}
return { name, value };
});
/**
* Parses a single media range with corresponding parameters from an Accept header.
* For every parameter value that is a double quoted string,
@ -163,7 +197,7 @@ const parseAcceptPart = (part: string, replacements: { [id: string]: string }):
// No reason to test differently for * since we don't check if the type exists
const [ type, subtype ] = range.split('/');
if (!type || !subtype || !token.test(type) || !token.test(subtype)) {
throw new Error(
throw new UnsupportedHttpError(
`Invalid Accept range: ${range} does not match ( "*/*" / ( token "/" "*" ) / ( token "/" token ) )`,
);
}
@ -172,33 +206,19 @@ const parseAcceptPart = (part: string, replacements: { [id: string]: string }):
const mediaTypeParams: { [key: string]: string } = {};
const extensionParams: { [key: string]: string } = {};
let map = mediaTypeParams;
parameters.forEach((param): void => {
const [ name, value ] = param.split('=');
const parsedParams = parseParameters(parameters, replacements);
parsedParams.forEach(({ name, value }): void => {
if (name === 'q') {
// Extension parameters appear after the q value
map = extensionParams;
testQValue(param);
testQValue(value);
weight = Number.parseFloat(value);
} else {
// Test replaced string for easier check
// parameter = token "=" ( token / quoted-string )
// second part is optional for extension parameters
if (!token.test(name) ||
!((map === extensionParams && !value) || (value && (/^"\d+"$/u.test(value) || token.test(value))))) {
throw new UnsupportedHttpError(
`Invalid Accept parameter: ${param} does not match (token "=" ( token / quoted-string )). ` +
`Second part is optional for extension parameters.`,
);
if (!value && map !== extensionParams) {
throw new UnsupportedHttpError(`Invalid Accept parameter ${name}: ` +
`Accept parameter values are not optional when preceding the q value.`);
}
let actualValue = value;
if (value && value.length > 0 && value.startsWith('"') && replacements[value]) {
actualValue = replacements[value];
}
// Value is optional for extension parameters
map[name] = actualValue || '';
map[name] = value || '';
}
});
@ -228,8 +248,12 @@ const parseNoParameters = (input: string): { range: string; weight: number }[] =
const [ range, qvalue ] = part.split(';').map((param): string => param.trim());
const result = { range, weight: 1 };
if (qvalue) {
testQValue(qvalue);
result.weight = Number.parseFloat(qvalue.split('=')[1]);
if (!qvalue.startsWith('q=')) {
throw new UnsupportedHttpError(`Only q parameters are allowed in ${input}.`);
}
const val = qvalue.slice(2);
testQValue(val);
result.weight = Number.parseFloat(val);
}
return result;
}).sort((left, right): number => right.weight - left.weight);

View File

@ -0,0 +1,38 @@
import { BasicMetadataExtractor } from '../../../../../src/ldp/http/metadata/BasicMetadataExtractor';
import type { MetadataParser } from '../../../../../src/ldp/http/metadata/MetadataParser';
import type { RepresentationMetadata } from '../../../../../src/ldp/representation/RepresentationMetadata';
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
import { RDF } from '../../../../../src/util/UriConstants';
class BasicParser implements MetadataParser {
private readonly header: string;
public constructor(header: string) {
this.header = header;
}
public async parse(input: HttpRequest, metadata: RepresentationMetadata): Promise<void> {
const header = input.headers[this.header];
if (header) {
if (typeof header === 'string') {
metadata.add(RDF.type, header);
}
}
}
}
describe(' A BasicMetadataExtractor', (): void => {
const handler = new BasicMetadataExtractor([
new BasicParser('aa'),
new BasicParser('bb'),
]);
it('can handle all requests.', async(): Promise<void> => {
await expect(handler.canHandle()).resolves.toBeUndefined();
});
it('will add metadata from the parsers.', async(): Promise<void> => {
const metadata = await handler.handle({ headers: { aa: 'valA', bb: 'valB' } as any } as HttpRequest);
expect(metadata.getAll(RDF.type).map((term): any => term.value)).toEqual([ 'valA', 'valB' ]);
});
});

View File

@ -0,0 +1,26 @@
import { ContentTypeParser } from '../../../../../src/ldp/http/metadata/ContentTypeParser';
import { RepresentationMetadata } from '../../../../../src/ldp/representation/RepresentationMetadata';
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
describe('A ContentTypeParser', (): void => {
const parser = new ContentTypeParser();
let request: HttpRequest;
let metadata: RepresentationMetadata;
beforeEach(async(): Promise<void> => {
request = { headers: {}} as HttpRequest;
metadata = new RepresentationMetadata();
});
it('does nothing if there is no content-type header.', async(): Promise<void> => {
await expect(parser.parse(request, metadata)).resolves.toBeUndefined();
expect(metadata.quads()).toHaveLength(0);
});
it('sets the given content-type as metadata.', async(): Promise<void> => {
request.headers['content-type'] = 'text/plain;charset=UTF-8';
await expect(parser.parse(request, metadata)).resolves.toBeUndefined();
expect(metadata.quads()).toHaveLength(1);
expect(metadata.contentType).toBe('text/plain');
});
});

View File

@ -0,0 +1,61 @@
import { LinkTypeParser } from '../../../../../src/ldp/http/metadata/LinkTypeParser';
import { RepresentationMetadata } from '../../../../../src/ldp/representation/RepresentationMetadata';
import { setGlobalLoggerFactory } from '../../../../../src/logging/LogUtil';
import { VoidLoggerFactory } from '../../../../../src/logging/VoidLoggerFactory';
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
import { RDF } from '../../../../../src/util/UriConstants';
describe('A LinkTypeParser', (): void => {
const parser = new LinkTypeParser();
let request: HttpRequest;
let metadata: RepresentationMetadata;
beforeAll(async(): Promise<void> => {
setGlobalLoggerFactory(new VoidLoggerFactory());
});
beforeEach(async(): Promise<void> => {
request = { headers: {}} as HttpRequest;
metadata = new RepresentationMetadata();
});
it('does nothing if there are no type headers.', async(): Promise<void> => {
await parser.parse(request, metadata);
expect(metadata.quads()).toHaveLength(0);
});
it('stores link headers with rel = type as metadata.', async(): Promise<void> => {
request.headers.link = '<http://test.com/type>;rel="type"';
await expect(parser.parse(request, metadata)).resolves.toBeUndefined();
expect(metadata.quads()).toHaveLength(1);
expect(metadata.get(RDF.type)?.value).toBe('http://test.com/type');
});
it('supports multiple link headers.', async(): Promise<void> => {
request.headers.link = [ '<http://test.com/typeA>;rel="type"', '<http://test.com/typeB>;rel=type' ];
await expect(parser.parse(request, metadata)).resolves.toBeUndefined();
expect(metadata.quads()).toHaveLength(2);
expect(metadata.getAll(RDF.type).map((term): any => term.value))
.toEqual([ 'http://test.com/typeA', 'http://test.com/typeB' ]);
});
it('supports multiple link header values in the same entry.', async(): Promise<void> => {
request.headers.link = '<http://test.com/typeA>;rel="type" , <http://test.com/typeB>;rel=type';
await expect(parser.parse(request, metadata)).resolves.toBeUndefined();
expect(metadata.quads()).toHaveLength(2);
expect(metadata.getAll(RDF.type).map((term): any => term.value))
.toEqual([ 'http://test.com/typeA', 'http://test.com/typeB' ]);
});
it('ignores invalid link headers.', async(): Promise<void> => {
request.headers.link = 'http://test.com/type;rel="type"';
await parser.parse(request, metadata);
expect(metadata.quads()).toHaveLength(0);
});
it('ignores non-type link headers.', async(): Promise<void> => {
request.headers.link = '<http://test.com/typeA>;rel="notype" , <http://test.com/typeB>';
await expect(parser.parse(request, metadata)).resolves.toBeUndefined();
expect(metadata.quads()).toHaveLength(0);
});
});

View File

@ -0,0 +1,34 @@
import { SlugParser } from '../../../../../src/ldp/http/metadata/SlugParser';
import { RepresentationMetadata } from '../../../../../src/ldp/representation/RepresentationMetadata';
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
import { UnsupportedHttpError } from '../../../../../src/util/errors/UnsupportedHttpError';
import { HTTP } from '../../../../../src/util/UriConstants';
describe('A SlugParser', (): void => {
const parser = new SlugParser();
let request: HttpRequest;
let metadata: RepresentationMetadata;
beforeEach(async(): Promise<void> => {
request = { headers: {}} as HttpRequest;
metadata = new RepresentationMetadata();
});
it('does nothing if there is no slug header.', async(): Promise<void> => {
await expect(parser.parse(request, metadata)).resolves.toBeUndefined();
expect(metadata.quads()).toHaveLength(0);
});
it('errors if there are multiple slug headers.', async(): Promise<void> => {
request.headers.slug = [ 'slugA', 'slugB' ];
await expect(parser.parse(request, metadata))
.rejects.toThrow(new UnsupportedHttpError('At most 1 slug header is allowed.'));
});
it('stores the slug metadata.', async(): Promise<void> => {
request.headers.slug = 'slugA';
await expect(parser.parse(request, metadata)).resolves.toBeUndefined();
expect(metadata.quads()).toHaveLength(1);
expect(metadata.get(HTTP.slug)?.value).toBe('slugA');
});
});

View File

@ -3,9 +3,9 @@ import {
parseAcceptCharset,
parseAcceptEncoding,
parseAcceptLanguage,
} from '../../../src/util/AcceptParser';
} from '../../../src/util/HeaderUtil';
describe('AcceptParser', (): void => {
describe('HeaderUtil', (): void => {
describe('parseAccept function', (): void => {
it('parses empty Accept headers.', async(): Promise<void> => {
expect(parseAccept('')).toEqual([]);
@ -39,7 +39,7 @@ describe('AcceptParser', (): void => {
expect(parseAccept('audio/basic; param1="val" ; q=0.5 ;param2="\\\\\\"valid"')).toEqual([
{ range: 'audio/basic',
weight: 0.5,
parameters: { mediaType: { param1: '"val"' }, extension: { param2: '"\\\\\\"valid"' }}},
parameters: { mediaType: { param1: 'val' }, extension: { param2: '\\\\\\"valid' }}},
]);
});
@ -59,15 +59,15 @@ describe('AcceptParser', (): void => {
});
it('rejects Accept Headers with invalid parameters.', async(): Promise<void> => {
expect((): any => parseAccept('a/b; a')).toThrow('Invalid Accept parameter:');
expect((): any => parseAccept('a/b; a=\\')).toThrow('Invalid Accept parameter:');
expect((): any => parseAccept('a/b; q=1 ; a=\\')).toThrow('Invalid Accept parameter:');
expect((): any => parseAccept('a/b; q=1 ; a')).not.toThrow('Invalid Accept parameter:');
expect((): any => parseAccept('a/b; a')).toThrow('Invalid Accept parameter');
expect((): any => parseAccept('a/b; a=\\')).toThrow('Invalid parameter value');
expect((): any => parseAccept('a/b; q=1 ; a=\\')).toThrow('Invalid parameter value');
expect((): any => parseAccept('a/b; q=1 ; a')).not.toThrow('Invalid Accept parameter');
});
it('rejects Accept Headers with quoted parameters.', async(): Promise<void> => {
expect((): any => parseAccept('a/b; a="\\""')).not.toThrow();
expect((): any => parseAccept('a/b; a="\\\u007F"')).toThrow('Invalid quoted string in Accept header:');
expect((): any => parseAccept('a/b; a="\\\u007F"')).toThrow('Invalid quoted string in header:');
});
});
@ -82,6 +82,7 @@ describe('AcceptParser', (): void => {
it('rejects invalid Accept-Charset Headers.', async(): Promise<void> => {
expect((): any => parseAcceptCharset('a/b')).toThrow('Invalid Accept-Charset range:');
expect((): any => parseAcceptCharset('a; q=text')).toThrow('Invalid q value:');
expect((): any => parseAcceptCharset('a; c=d')).toThrow('Only q parameters are allowed');
});
});
@ -101,6 +102,7 @@ describe('AcceptParser', (): void => {
it('rejects invalid Accept-Encoding Headers.', async(): Promise<void> => {
expect((): any => parseAcceptEncoding('a/b')).toThrow('Invalid Accept-Encoding range:');
expect((): any => parseAcceptEncoding('a; q=text')).toThrow('Invalid q value:');
expect((): any => parseAcceptCharset('a; c=d')).toThrow('Only q parameters are allowed');
});
});
@ -122,6 +124,7 @@ describe('AcceptParser', (): void => {
expect((): any => parseAcceptLanguage('a-b-c-d')).not.toThrow('Invalid Accept-Language range:');
expect((): any => parseAcceptLanguage('a; q=text')).toThrow('Invalid q value:');
expect((): any => parseAcceptCharset('a; c=d')).toThrow('Only q parameters are allowed');
});
});
});