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

@@ -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);