Skip to content

Commit

Permalink
feat: Create MetadataHandler
Browse files Browse the repository at this point in the history
  • Loading branch information
joachimvh committed Oct 6, 2020
1 parent 4d34cdd commit 71a7a93
Show file tree
Hide file tree
Showing 14 changed files with 350 additions and 41 deletions.
2 changes: 1 addition & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,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';
Expand Down
4 changes: 2 additions & 2 deletions src/ldp/http/AcceptPreferenceParser.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
29 changes: 29 additions & 0 deletions src/ldp/http/metadata/BasicMetadataHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { HttpRequest } from '../../../server/HttpRequest';
import { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { MetadataHandler } from './MetadataHandler';
import type { MetadataParser } from './MetadataParser';

/**
* MetadataHandler that lets each of its MetadataParsers add metadata based on the HttpRequest.
*/
export class BasicMetadataHandler extends MetadataHandler {
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;
}
}
17 changes: 17 additions & 0 deletions src/ldp/http/metadata/ContentTypeParser.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
38 changes: 38 additions & 0 deletions src/ldp/http/metadata/LinkTypeParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { DataFactory } from 'n3';
import type { HttpRequest } from '../../../server/HttpRequest';
import { UnsupportedHttpError } from '../../../util/errors/UnsupportedHttpError';
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 {
public async parse(request: HttpRequest, metadata: RepresentationMetadata): Promise<void> {
const { link } = request.headers;
if (link) {
if (Array.isArray(link)) {
link.forEach((entry): void => this.parseLink(entry, metadata));
} else {
this.parseLink(link, metadata);
}
}
}

private parseLink(linkEntry: string, metadata: RepresentationMetadata): void {
const { result, replacements } = transformQuotedStrings(linkEntry);
splitAndClean(result).forEach((part): void => {
const [ link, ...parameters ] = part.split(';');
if (!link.startsWith('<') || !link.endsWith('>')) {
throw new UnsupportedHttpError(`Invalid link header ${part}.`);
}
parseParameters(parameters, replacements).forEach(({ name, value }): void => {
if (name === 'rel' && value === 'type') {
metadata.add(RDF.type, DataFactory.namedNode(link.slice(1, -1)));
}
});
});
}
}
9 changes: 9 additions & 0 deletions src/ldp/http/metadata/MetadataHandler.ts
Original file line number Diff line number Diff line change
@@ -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 MetadataHandler extends
AsyncHandler<HttpRequest, RepresentationMetadata> {}
15 changes: 15 additions & 0 deletions src/ldp/http/metadata/MetadataParser.ts
Original file line number Diff line number Diff line change
@@ -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>;
}
20 changes: 20 additions & 0 deletions src/ldp/http/metadata/SlugParser.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
84 changes: 54 additions & 30 deletions src/util/AcceptParser.ts → src/util/HeaderUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand All @@ -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);
Expand All @@ -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, value ] = 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) && (!value || /^"\d+"$/u.test(value) || token.test(value)))) {
throw new UnsupportedHttpError(
`Invalid parameter value: ${name}=${replacements[value] || value} ` +
`does not match (token ( "=" ( token / quoted-string ))?). `,
);
}

let actualValue = value;
if (value && value.length > 0 && value.startsWith('"') && replacements[value]) {
actualValue = replacements[value];
}

return { name, value: actualValue };
});

/**
* Parses a single media range with corresponding parameters from an Accept header.
* For every parameter value that is a double quoted string,
Expand All @@ -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 ) )`,
);
}
Expand All @@ -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.`,
);
}

let actualValue = value;
if (value && value.length > 0 && value.startsWith('"') && replacements[value]) {
actualValue = replacements[value];
if (!value && map !== extensionParams) {
throw new UnsupportedHttpError(`Invalid Accept parameter ${name}: ` +
`Accept parameter values are not optional when preceding the q value.`);
}

// Value is optional for extension parameters
map[name] = actualValue || '';
map[name] = value || '';
}
});

Expand Down Expand Up @@ -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);
Expand Down
38 changes: 38 additions & 0 deletions test/unit/ldp/http/metadata/BasicMetadataHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { BasicMetadataHandler } from '../../../../../src/ldp/http/metadata/BasicMetadataHandler';
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 BasicMetadataHandler', (): void => {
const handler = new BasicMetadataHandler([
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' ]);
});
});
26 changes: 26 additions & 0 deletions test/unit/ldp/http/metadata/ContentTypeParser.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});

0 comments on commit 71a7a93

Please sign in to comment.