feat: Add metadata to errors

This commit is contained in:
Joachim Van Herwegen
2023-07-25 14:10:46 +02:00
parent a333412e19
commit f373dff1d7
42 changed files with 455 additions and 419 deletions

View File

@@ -1,6 +1,7 @@
import type { TLSSocket } from 'tls';
import type { HttpRequest } from '../../../server/HttpRequest';
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
import { errorTermsToMetadata } from '../../../util/errors/HttpErrorUtil';
import { InternalServerError } from '../../../util/errors/InternalServerError';
import { parseForwarded } from '../../../util/HeaderUtil';
import type { IdentifierStrategy } from '../../../util/identifiers/IdentifierStrategy';
@@ -73,7 +74,7 @@ export class OriginalUrlExtractor extends TargetExtractor {
// Check if the configured IdentifierStrategy supports the identifier
if (!this.identifierStrategy.supportsIdentifier(identifier)) {
throw new InternalServerError(`The identifier ${identifier.path} is outside the configured identifier space.`,
{ errorCode: 'E0001', details: { path: identifier.path }});
{ errorCode: 'E0001', metadata: errorTermsToMetadata({ path: identifier.path }) });
}
return identifier;

View File

@@ -1,4 +1,5 @@
import type { HttpRequest } from '../../../server/HttpRequest';
import { parseContentType } from '../../../util/HeaderUtil';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { MetadataParser } from './MetadataParser';
@@ -9,7 +10,7 @@ export class ContentTypeParser extends MetadataParser {
public async handle(input: { request: HttpRequest; metadata: RepresentationMetadata }): Promise<void> {
const contentType = input.request.headers['content-type'];
if (contentType) {
input.metadata.contentType = contentType;
input.metadata.contentTypeObject = parseContentType(contentType);
}
}
}

View File

@@ -1,5 +1,5 @@
import type { HttpRequest } from '../../../server/HttpRequest';
import type { AcceptHeader } from '../../../util/HeaderUtil';
import type { AcceptHeader } from '../../../util/Header';
import {
parseAccept,
parseAcceptCharset,

View File

@@ -3,13 +3,9 @@ import type {
RepresentationConverterArgs,
} from '../../../storage/conversion/RepresentationConverter';
import { INTERNAL_ERROR } from '../../../util/ContentTypes';
import { getStatusCode } from '../../../util/errors/HttpErrorUtil';
import { toLiteral } from '../../../util/TermUtil';
import { HTTP, XSD } from '../../../util/Vocabularies';
import type { PreferenceParser } from '../../input/preferences/PreferenceParser';
import { BasicRepresentation } from '../../representation/BasicRepresentation';
import type { Representation } from '../../representation/Representation';
import { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import type { ResponseDescription } from '../response/ResponseDescription';
import type { ErrorHandlerArgs } from './ErrorHandler';
import { ErrorHandler } from './ErrorHandler';
@@ -64,11 +60,13 @@ export class ConvertingErrorHandler extends ErrorHandler {
* Prepares the arguments used by all functions.
*/
private async extractErrorDetails({ error, request }: ErrorHandlerArgs): Promise<PreparedArguments> {
const statusCode = getStatusCode(error);
const representation = this.toRepresentation(error, statusCode);
if (!this.showStackTrace) {
delete error.stack;
}
const representation = new BasicRepresentation([ error ], error.metadata, INTERNAL_ERROR, false);
const identifier = { path: representation.metadata.identifier.value };
const preferences = await this.preferenceParser.handle({ request });
return { statusCode, conversionArgs: { identifier, representation, preferences }};
return { statusCode: error.statusCode, conversionArgs: { identifier, representation, preferences }};
}
/**
@@ -81,20 +79,4 @@ export class ConvertingErrorHandler extends ErrorHandler {
data: converted.data,
};
}
/**
* Creates a Representation based on the given error.
* Content type will be internal/error.
* The status code is used for metadata.
*/
private toRepresentation(error: Error, statusCode: number): Representation {
const metadata = new RepresentationMetadata(INTERNAL_ERROR);
metadata.add(HTTP.terms.statusCodeNumber, toLiteral(statusCode, XSD.terms.integer));
if (!this.showStackTrace) {
delete error.stack;
}
return new BasicRepresentation([ error ], metadata, false);
}
}

View File

@@ -1,9 +1,10 @@
import type { HttpRequest } from '../../../server/HttpRequest';
import type { HttpError } from '../../../util/errors/HttpError';
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
import type { ResponseDescription } from '../response/ResponseDescription';
export interface ErrorHandlerArgs {
error: Error;
error: HttpError;
request: HttpRequest;
}

View File

@@ -1,10 +1,6 @@
import { getLoggerFor } from '../../../logging/LogUtil';
import { createErrorMessage } from '../../../util/errors/ErrorUtil';
import { getStatusCode } from '../../../util/errors/HttpErrorUtil';
import { guardedStreamFrom } from '../../../util/StreamUtil';
import { toLiteral } from '../../../util/TermUtil';
import { HTTP, XSD } from '../../../util/Vocabularies';
import { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import type { ResponseDescription } from '../response/ResponseDescription';
import type { ErrorHandlerArgs } from './ErrorHandler';
import { ErrorHandler } from './ErrorHandler';
@@ -32,17 +28,15 @@ export class SafeErrorHandler extends ErrorHandler {
this.logger.debug(`Recovering from error handler failure: ${createErrorMessage(error)}`);
}
const { error } = input;
const statusCode = getStatusCode(error);
const metadata = new RepresentationMetadata('text/plain');
metadata.add(HTTP.terms.statusCodeNumber, toLiteral(statusCode, XSD.terms.integer));
error.metadata.contentType = 'text/plain';
const text = typeof error.stack === 'string' && this.showStackTrace ?
`${error.stack}\n` :
`${error.name}: ${error.message}\n`;
return {
statusCode,
metadata,
statusCode: error.statusCode,
metadata: error.metadata,
data: guardedStreamFrom(text),
};
}

View File

@@ -1,7 +1,6 @@
import { DataFactory } from 'n3';
import type { RedirectHttpError } from '../../../util/errors/RedirectHttpError';
import { SOLID_HTTP } from '../../../util/Vocabularies';
import { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { ResponseDescription } from './ResponseDescription';
/**
@@ -9,7 +8,7 @@ import { ResponseDescription } from './ResponseDescription';
*/
export class RedirectResponseDescription extends ResponseDescription {
public constructor(error: RedirectHttpError) {
const metadata = new RepresentationMetadata({ [SOLID_HTTP.location]: DataFactory.namedNode(error.location) });
super(error.statusCode, metadata);
error.metadata.set(SOLID_HTTP.terms.location, DataFactory.namedNode(error.location));
super(error.statusCode, error.metadata);
}
}

View File

@@ -1,8 +1,7 @@
import { DataFactory, Store } from 'n3';
import type { BlankNode, DefaultGraph, Literal, NamedNode, Quad, Term } from 'rdf-js';
import { getLoggerFor } from '../../logging/LogUtil';
import { InternalServerError } from '../../util/errors/InternalServerError';
import { ContentType, parseContentType } from '../../util/HeaderUtil';
import { ContentType, SIMPLE_MEDIA_RANGE } from '../../util/Header';
import { toNamedTerm, toObjectTerm, isTerm, toLiteral } from '../../util/TermUtil';
import { CONTENT_TYPE_TERM, CONTENT_LENGTH_TERM, XSD, SOLID_META, RDFS } from '../../util/Vocabularies';
import type { ResourceIdentifier } from './ResourceIdentifier';
@@ -67,18 +66,18 @@ export class RepresentationMetadata {
* @param identifier - Identifier of the resource relevant to this metadata.
* @param contentType - Override for the content type of the representation.
*/
public constructor(identifier?: MetadataIdentifier, contentType?: string);
public constructor(identifier?: MetadataIdentifier, contentType?: string | ContentType);
/**
* @param metadata - Starts as a copy of the input metadata.
* @param contentType - Override for the content type of the representation.
*/
public constructor(metadata?: RepresentationMetadata, contentType?: string);
public constructor(metadata?: RepresentationMetadata, contentType?: string | ContentType);
/**
* @param contentType - The content type of the representation.
*/
public constructor(contentType?: string);
public constructor(contentType?: string | ContentType);
/**
* @param metadata - Metadata values (defaulting to content type if a string)
@@ -86,8 +85,8 @@ export class RepresentationMetadata {
public constructor(metadata?: RepresentationMetadata | MetadataRecord | string);
public constructor(
input?: MetadataIdentifier | RepresentationMetadata | MetadataRecord | string,
overrides?: MetadataRecord | string,
input?: MetadataIdentifier | RepresentationMetadata | MetadataRecord | ContentType | string,
overrides?: MetadataRecord | string | ContentType,
) {
this.store = new Store();
if (isResourceIdentifier(input)) {
@@ -105,6 +104,8 @@ export class RepresentationMetadata {
if (overrides) {
if (typeof overrides === 'string') {
this.contentType = overrides;
} else if (overrides instanceof ContentType) {
this.contentTypeObject = overrides;
} else {
this.setOverrides(overrides);
}
@@ -313,7 +314,8 @@ export class RepresentationMetadata {
}
if (terms.length > 1) {
this.logger.error(`Multiple results for ${predicate.value}`);
throw new InternalServerError(
// We can not use an `InternalServerError` here as otherwise errors and metadata files would depend on each other
throw new Error(
`Multiple results for ${predicate.value}`,
);
}
@@ -344,7 +346,16 @@ export class RepresentationMetadata {
}
if (typeof input === 'string') {
input = parseContentType(input);
// Simple check to estimate if this is a simple content type.
// If not, mention that the `contentTypeObject` should be used instead.
// Not calling `parseContentType` here as that would cause a dependency loop with `HttpError`.
if (!SIMPLE_MEDIA_RANGE.test(input)) {
// Not using an HttpError as HttpError depends on metadata
throw new Error(
'Only simple content types can be set by string. Use the `contentTypeObject` function for complexer types.',
);
}
input = new ContentType(input);
}
for (const [ key, value ] of Object.entries(input.parameters)) {

View File

@@ -20,6 +20,7 @@ import { getLoggerFor } from '../../logging/LogUtil';
import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage';
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
import type { HttpError } from '../../util/errors/HttpError';
import { errorTermsToMetadata } from '../../util/errors/HttpErrorUtil';
import { InternalServerError } from '../../util/errors/InternalServerError';
import { OAuthHttpError } from '../../util/errors/OAuthHttpError';
import { RedirectHttpError } from '../../util/errors/RedirectHttpError';
@@ -398,10 +399,10 @@ export class IdentityProviderFactory implements ProviderFactory {
const unknownClientError = new BadRequestHttpError(
'Unknown client, you might need to clear the local storage on the client.', {
errorCode: 'E0003',
details: {
client_id: ctx.request.query.client_id,
redirect_uri: ctx.request.query.redirect_uri,
},
metadata: errorTermsToMetadata({
client_id: ctx.request.query.client_id as string,
redirect_uri: ctx.request.query.redirect_uri as string,
}),
},
);
unknownClientError.stack = oidcError.stack;

View File

@@ -4,6 +4,7 @@ import { v4 } from 'uuid';
import { getLoggerFor } from '../../logging/LogUtil';
import type { ExpiringStorage } from '../../storage/keyvalue/ExpiringStorage';
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
import { errorTermsToMetadata } from '../../util/errors/HttpErrorUtil';
import { fetchDataset } from '../../util/FetchUtil';
import { SOLID } from '../../util/Vocabularies';
import { OwnershipValidator } from './OwnershipValidator';
@@ -89,6 +90,6 @@ export class TokenOwnershipValidator extends OwnershipValidator {
'You can remove this triple again after validation.',
].join(' ');
const details = { quad: `<${webId}> <${SOLID.oidcIssuerRegistrationToken}> "${token}".` };
throw new BadRequestHttpError(errorMessage, { details });
throw new BadRequestHttpError(errorMessage, { metadata: errorTermsToMetadata(details) });
}
}

View File

@@ -3,8 +3,9 @@ import type { ErrorHandler } from '../http/output/error/ErrorHandler';
import type { ResponseDescription } from '../http/output/response/ResponseDescription';
import type { ResponseWriter } from '../http/output/ResponseWriter';
import { getLoggerFor } from '../logging/LogUtil';
import { assertError } from '../util/errors/ErrorUtil';
import { createErrorMessage } from '../util/errors/ErrorUtil';
import { HttpError } from '../util/errors/HttpError';
import { InternalServerError } from '../util/errors/InternalServerError';
import type { HttpHandlerInput } from './HttpHandler';
import { HttpHandler } from './HttpHandler';
import type { HttpRequest } from './HttpRequest';
@@ -81,12 +82,11 @@ export class ParsingHttpHandler extends HttpHandler {
* Handles the error output correctly based on the preferences.
*/
protected async handleError(error: unknown, request: HttpRequest): Promise<ResponseDescription> {
assertError(error);
const result = await this.errorHandler.handleSafe({ error, request });
if (HttpError.isInstance(error) && result.metadata) {
const quads = error.generateMetadata(result.metadata.identifier);
result.metadata.addQuads(quads);
if (!HttpError.isInstance(error)) {
error = new InternalServerError(`Received unexpected non-HttpError: ${createErrorMessage(error)}`,
{ cause: error });
}
return result;
return this.errorHandler.handleSafe({ error: error as HttpError, request });
}
}

View File

@@ -1,7 +1,8 @@
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import type { Representation } from '../../http/representation/Representation';
import { APPLICATION_JSON, INTERNAL_ERROR } from '../../util/ContentTypes';
import { HttpError } from '../../util/errors/HttpError';
import type { HttpError } from '../../util/errors/HttpError';
import { extractErrorTerms } from '../../util/errors/HttpErrorUtil';
import { OAuthHttpError } from '../../util/errors/OAuthHttpError';
import { getSingleItem } from '../../util/StreamUtil';
import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter';
@@ -16,11 +17,14 @@ export class ErrorToJsonConverter extends BaseTypedRepresentationConverter {
}
public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
const error = await getSingleItem(representation.data) as Error;
const error = await getSingleItem(representation.data) as HttpError;
const result: Record<string, any> = {
name: error.name,
message: error.message,
statusCode: error.statusCode,
errorCode: error.errorCode,
details: extractErrorTerms(error.metadata),
};
// OAuth errors responses require additional fields
@@ -28,22 +32,6 @@ export class ErrorToJsonConverter extends BaseTypedRepresentationConverter {
Object.assign(result, error.mandatoryFields);
}
if (HttpError.isInstance(error)) {
result.statusCode = error.statusCode;
result.errorCode = error.errorCode;
if (error.details) {
try {
// The details might not be serializable
JSON.stringify(error.details);
result.details = error.details;
} catch {
// Do not store the details
}
}
} else {
result.statusCode = 500;
}
if (error.stack) {
result.stack = error.stack;
}

View File

@@ -2,8 +2,9 @@ import { BasicRepresentation } from '../../http/representation/BasicRepresentati
import type { Representation } from '../../http/representation/Representation';
import { RepresentationMetadata } from '../../http/representation/RepresentationMetadata';
import { INTERNAL_ERROR, INTERNAL_QUADS } from '../../util/ContentTypes';
import type { HttpError } from '../../util/errors/HttpError';
import { getSingleItem } from '../../util/StreamUtil';
import { DC, SOLID_ERROR } from '../../util/Vocabularies';
import { DC, SOLID_ERROR, SOLID_ERROR_TERM } from '../../util/Vocabularies';
import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter';
import type { RepresentationConverterArgs } from './RepresentationConverter';
@@ -16,7 +17,7 @@ export class ErrorToQuadConverter extends BaseTypedRepresentationConverter {
}
public async handle({ identifier, representation }: RepresentationConverterArgs): Promise<Representation> {
const error = await getSingleItem(representation.data) as Error;
const error = await getSingleItem(representation.data) as HttpError;
// A metadata object makes it easier to add triples due to the utility functions
const data = new RepresentationMetadata(identifier);
@@ -25,6 +26,9 @@ export class ErrorToQuadConverter extends BaseTypedRepresentationConverter {
if (error.stack) {
data.add(SOLID_ERROR.terms.stack, error.stack);
}
// Add all the error terms from the metadata
data.addQuads(representation.metadata.quads()
.filter((quad): boolean => quad.predicate.value.startsWith(SOLID_ERROR_TERM.namespace)));
// Update the content-type to quads
return new BasicRepresentation(data.quads(), representation.metadata, INTERNAL_QUADS, false);

View File

@@ -2,7 +2,8 @@ import assert from 'assert';
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import type { Representation } from '../../http/representation/Representation';
import { INTERNAL_ERROR } from '../../util/ContentTypes';
import { HttpError } from '../../util/errors/HttpError';
import type { HttpError } from '../../util/errors/HttpError';
import { extractErrorTerms } from '../../util/errors/HttpErrorUtil';
import { resolveModulePath } from '../../util/PathUtil';
import { getSingleItem } from '../../util/StreamUtil';
import { isValidFileName } from '../../util/StringUtil';
@@ -57,19 +58,20 @@ export class ErrorToTemplateConverter extends BaseTypedRepresentationConverter {
}
public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
const error = await getSingleItem(representation.data) as Error;
const error = await getSingleItem(representation.data) as HttpError;
// Render the error description using an error-specific template
let description: string | undefined;
if (HttpError.isInstance(error)) {
try {
const templateFile = `${error.errorCode}${this.extension}`;
assert(isValidFileName(templateFile), 'Invalid error template name');
description = await this.templateEngine.handleSafe({ contents: error.details ?? {},
template: { templateFile, templatePath: this.codeTemplatesPath }});
} catch {
// In case no template is found, or rendering errors, we still want to convert
}
try {
const templateFile = `${error.errorCode}${this.extension}`;
assert(isValidFileName(templateFile), 'Invalid error template name');
// Filter out the error terms to pass to the template
description = await this.templateEngine.handleSafe({
contents: extractErrorTerms(error.metadata),
template: { templateFile, templatePath: this.codeTemplatesPath },
});
} catch {
// In case no template is found, or rendering errors, we still want to convert
}
// Render the main template, embedding the rendered error description

124
src/util/Header.ts Normal file
View File

@@ -0,0 +1,124 @@
// The interfaces here are split off from HttpErrorUtil.ts to prevent a dependency loop in RepresentationMetadata
/**
* General interface for all Accept* headers.
*/
export interface AcceptHeader {
/** Requested range. Can be a specific value or `*`, matching all. */
range: string;
/** Weight of the preference [0, 1]. */
weight: number;
}
/**
* Contents of an HTTP Accept header.
* Range is type/subtype. Both can be `*`.
*/
export interface Accept extends AcceptHeader {
parameters: {
/** Media type parameters. These are the parameters that came before the q value. */
mediaType: Record<string, string>;
/**
* Extension parameters. These are the parameters that came after the q value.
* Value will be an empty string if there was none.
*/
extension: Record<string, string>;
};
}
/**
* Contents of an HTTP Accept-Charset header.
*/
export interface AcceptCharset extends AcceptHeader { }
/**
* Contents of an HTTP Accept-Encoding header.
*/
export interface AcceptEncoding extends AcceptHeader { }
/**
* Contents of an HTTP Accept-Language header.
*/
export interface AcceptLanguage extends AcceptHeader { }
/**
* Contents of an HTTP Accept-Datetime header.
*/
export interface AcceptDatetime extends AcceptHeader { }
/**
* Contents of an HTTP Content-Type Header.
* Optional parameters Record is included.
*/
export class ContentType {
public constructor(public value: string, public parameters: Record<string, string> = {}) {}
/**
* Serialize this ContentType object to a ContentType header appropriate value string.
* @returns The value string, including parameters, if present.
*/
public toHeaderValueString(): string {
return Object.entries(this.parameters)
.sort((entry1, entry2): number => entry1[0].localeCompare(entry2[0]))
.reduce((acc, entry): string => `${acc}; ${entry[0]}=${entry[1]}`, this.value);
}
}
export interface LinkEntryParameters extends Record<string, string> {
/** Required rel properties of Link entry */
rel: string;
}
export interface LinkEntry {
target: string;
parameters: LinkEntryParameters;
}
// BNF based on https://tools.ietf.org/html/rfc7231
//
// media-type = type "/" subtype *( OWS ";" OWS parameter )
//
// media-range = ( "*/*"
// / ( type "/" "*" )
// / ( type "/" subtype )
// ) *( OWS ";" OWS parameter ) ; media type parameters
// accept-params = weight *( accept-ext )
// accept-ext = OWS ";" OWS token [ "=" ( token / quoted-string ) ] ; extension parameters
//
// weight = OWS ";" OWS "q=" qvalue
// qvalue = ( "0" [ "." 0*3DIGIT ] )
// / ( "1" [ "." 0*3("0") ] )
//
// type = token
// subtype = token
// parameter = token "=" ( token / quoted-string )
//
// quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE
// qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text
// obs-text = %x80-FF
// quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
//
// charset = token
//
// codings = content-coding / "identity" / "*"
// content-coding = token
//
// language-range = (1*8ALPHA *("-" 1*8alphanum)) / "*"
// alphanum = ALPHA / DIGIT
//
// Delimiters are chosen from the set of US-ASCII visual characters
// not allowed in a token (DQUOTE and "(),/:;<=>?@[\]{}").
// token = 1*tchar
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*"
// / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
// / DIGIT / ALPHA
// ; any VCHAR, except delimiters
//
// REUSED REGEXES
export const TCHAR = /[a-zA-Z0-9!#$%&'*+-.^_`|~]/u;
export const TOKEN = new RegExp(`^${TCHAR.source}+$`, 'u');
export const SIMPLE_MEDIA_RANGE = new RegExp(`^${TCHAR.source}+/${TCHAR.source}+$`, 'u');
export const QUOTED_STRING =
/^"(?:[\t !\u0023-\u005B\u005D-\u007E\u0080-\u00FF]|(\\[\t\u0020-\u007E\u0080-\u00FF]))*"$/u;
export const QVALUE = /^(?:(0(?:\.\d{0,3})?)|(1(?:\.0{0,3})?))$/u;

View File

@@ -3,10 +3,18 @@ import escapeStringRegexp from 'escape-string-regexp';
import { getLoggerFor } from '../logging/LogUtil';
import type { HttpResponse } from '../server/HttpResponse';
import { BadRequestHttpError } from './errors/BadRequestHttpError';
import type {
Accept,
AcceptCharset,
AcceptDatetime,
AcceptEncoding,
AcceptHeader,
AcceptLanguage,
LinkEntry,
} from './Header';
import { ContentType, SIMPLE_MEDIA_RANGE, QUOTED_STRING, QVALUE, TOKEN } from './Header';
const logger = getLoggerFor('HeaderUtil');
// Map used as a simple cache in the helper function matchesAuthorizationScheme.
const authSchemeRegexCache: Map<string, RegExp> = new Map();
// BNF based on https://tools.ietf.org/html/rfc7231
//
@@ -16,124 +24,6 @@ const authSchemeRegexCache: Map<string, RegExp> = new Map();
// Accept-Language = 1#( language-range [ weight ] )
//
// Content-Type = media-type
// media-type = type "/" subtype *( OWS ";" OWS parameter )
//
// media-range = ( "*/*"
// / ( type "/" "*" )
// / ( type "/" subtype )
// ) *( OWS ";" OWS parameter ) ; media type parameters
// accept-params = weight *( accept-ext )
// accept-ext = OWS ";" OWS token [ "=" ( token / quoted-string ) ] ; extension parameters
//
// weight = OWS ";" OWS "q=" qvalue
// qvalue = ( "0" [ "." 0*3DIGIT ] )
// / ( "1" [ "." 0*3("0") ] )
//
// type = token
// subtype = token
// parameter = token "=" ( token / quoted-string )
//
// quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE
// qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text
// obs-text = %x80-FF
// quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
//
// charset = token
//
// codings = content-coding / "identity" / "*"
// content-coding = token
//
// language-range = (1*8ALPHA *("-" 1*8alphanum)) / "*"
// alphanum = ALPHA / DIGIT
//
// Delimiters are chosen from the set of US-ASCII visual characters
// not allowed in a token (DQUOTE and "(),/:;<=>?@[\]{}").
// token = 1*tchar
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*"
// / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
// / DIGIT / ALPHA
// ; any VCHAR, except delimiters
//
// INTERFACES
/**
* General interface for all Accept* headers.
*/
export interface AcceptHeader {
/** Requested range. Can be a specific value or `*`, matching all. */
range: string;
/** Weight of the preference [0, 1]. */
weight: number;
}
/**
* Contents of an HTTP Accept header.
* Range is type/subtype. Both can be `*`.
*/
export interface Accept extends AcceptHeader {
parameters: {
/** Media type parameters. These are the parameters that came before the q value. */
mediaType: Record<string, string>;
/**
* Extension parameters. These are the parameters that came after the q value.
* Value will be an empty string if there was none.
*/
extension: Record<string, string>;
};
}
/**
* Contents of an HTTP Accept-Charset header.
*/
export interface AcceptCharset extends AcceptHeader { }
/**
* Contents of an HTTP Accept-Encoding header.
*/
export interface AcceptEncoding extends AcceptHeader { }
/**
* Contents of an HTTP Accept-Language header.
*/
export interface AcceptLanguage extends AcceptHeader { }
/**
* Contents of an HTTP Accept-Datetime header.
*/
export interface AcceptDatetime extends AcceptHeader { }
/**
* Contents of a HTTP Content-Type Header.
* Optional parameters Record is included.
*/
export class ContentType {
public constructor(public value: string, public parameters: Record<string, string> = {}) {}
/**
* Serialize this ContentType object to a ContentType header appropriate value string.
* @returns The value string, including parameters, if present.
*/
public toHeaderValueString(): string {
return Object.entries(this.parameters)
.sort((entry1, entry2): number => entry1[0].localeCompare(entry2[0]))
.reduce((acc, entry): string => `${acc}; ${entry[0]}=${entry[1]}`, this.value);
}
}
export interface LinkEntryParameters extends Record<string, string> {
/** Required rel properties of Link entry */
rel: string;
}
export interface LinkEntry {
target: string;
parameters: LinkEntryParameters;
}
// REUSED REGEXES
const tchar = /[a-zA-Z0-9!#$%&'*+-.^_`|~]/u;
const token = new RegExp(`^${tchar.source}+$`, 'u');
const mediaRange = new RegExp(`${tchar.source}+/${tchar.source}+`, 'u');
// HELPER FUNCTIONS
/**
@@ -150,7 +40,7 @@ export function transformQuotedStrings(input: string): { result: string; replace
const replacements: Record<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)) {
if (!QUOTED_STRING.test(match)) {
logger.warn(`Invalid quoted string in header: ${match}`);
throw new BadRequestHttpError(`Invalid quoted string in header: ${match}`);
}
@@ -175,17 +65,6 @@ export function splitAndClean(input: string): string[] {
.filter((part): boolean => part.length > 0);
}
/**
* Checks if the input string matches the qvalue regex.
*
* @param qvalue - Input qvalue string (so "q=....").
*
* @returns true if q value is valid, false otherwise.
*/
function isValidQValue(qvalue: string): boolean {
return /^(?:(?:0(?:\.\d{0,3})?)|(?:1(?:\.0{0,3})?))$/u.test(qvalue);
}
/**
* Converts a qvalue to a number.
* Returns 1 if the value is not a valid number or 1 if it is more than 1.
@@ -237,7 +116,7 @@ export function parseParameters(parameters: string[], replacements: Record<strin
// 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)))) {
if (!(TOKEN.test(name) && (!rawValue || /^"\d+"$/u.test(rawValue) || TOKEN.test(rawValue)))) {
handleInvalidValue(`Invalid parameter value: ${name}=${replacements[rawValue] || rawValue} ` +
`does not match (token ( "=" ( token / quoted-string ))?). `, strict);
return acc;
@@ -271,7 +150,7 @@ function parseAcceptPart(part: string, replacements: Record<string, string>, str
const [ range, ...parameters ] = part.split(';').map((param): string => param.trim());
// No reason to test differently for * since we don't check if the type exists
if (!mediaRange.test(range)) {
if (!SIMPLE_MEDIA_RANGE.test(range)) {
handleInvalidValue(
`Invalid Accept range: ${range} does not match ( "*/*" / ( token "/" "*" ) / ( token "/" token ) )`, strict,
);
@@ -287,7 +166,7 @@ function parseAcceptPart(part: string, replacements: Record<string, string>, str
if (name === 'q') {
// Extension parameters appear after the q value
map = extensionParams;
if (!isValidQValue(value)) {
if (!QVALUE.test(value)) {
handleInvalidValue(`Invalid q value for range ${range}: ${value
} does not match ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3("0") ] ).`, strict);
}
@@ -332,7 +211,7 @@ function parseNoParameters(input: string, strict = false): AcceptHeader[] {
return result;
}
const val = qvalue.slice(2);
if (!isValidQValue(val)) {
if (!QVALUE.test(val)) {
handleInvalidValue(`Invalid q value for range ${range}: ${val
} does not match ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3("0") ] ).`, strict);
}
@@ -382,7 +261,7 @@ export function parseAccept(input: string, strict = false): Accept[] {
export function parseAcceptCharset(input: string, strict = false): AcceptCharset[] {
const results = parseNoParameters(input);
return results.filter((result): boolean => {
if (!token.test(result.range)) {
if (!TOKEN.test(result.range)) {
handleInvalidValue(
`Invalid Accept-Charset range: ${result.range} does not match (content-coding / "identity" / "*")`, strict,
);
@@ -404,7 +283,7 @@ export function parseAcceptCharset(input: string, strict = false): AcceptCharset
export function parseAcceptEncoding(input: string, strict = false): AcceptEncoding[] {
const results = parseNoParameters(input);
return results.filter((result): boolean => {
if (!token.test(result.range)) {
if (!TOKEN.test(result.range)) {
handleInvalidValue(`Invalid Accept-Encoding range: ${result.range} does not match (charset / "*")`, strict);
return false;
}
@@ -495,7 +374,7 @@ export function parseContentType(input: string): ContentType {
// Quoted strings could prevent split from having correct results
const { result, replacements } = transformQuotedStrings(input);
const [ value, ...params ] = result.split(';').map((str): string => str.trim());
if (!mediaRange.test(value)) {
if (!SIMPLE_MEDIA_RANGE.test(value)) {
logger.warn(`Invalid content-type: ${value}`);
throw new BadRequestHttpError(`Invalid content-type: ${value} does not match ( token "/" token )`);
}
@@ -595,6 +474,8 @@ export function parseLinkHeader(link: string | string[] = []): LinkEntry[] {
return links;
}
// Map used as a simple cache in the helper function matchesAuthorizationScheme.
const authSchemeRegexCache: Map<string, RegExp> = new Map();
/**
* Checks if the value of an HTTP Authorization header matches a specific scheme (e.g. Basic, Bearer, etc).
*

View File

@@ -5,6 +5,7 @@ import type { TargetExtractor } from '../http/input/identifier/TargetExtractor';
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
import type { HttpRequest } from '../server/HttpRequest';
import { BadRequestHttpError } from './errors/BadRequestHttpError';
import { errorTermsToMetadata } from './errors/HttpErrorUtil';
/**
* Changes a potential Windows path into a POSIX path.
@@ -235,7 +236,7 @@ Promise<string> {
const target = await targetExtractor.handleSafe({ request });
if (!target.path.startsWith(baseUrl)) {
throw new BadRequestHttpError(`The identifier ${target.path} is outside the configured identifier space.`,
{ errorCode: 'E0001', details: { path: target.path }});
{ errorCode: 'E0001', metadata: errorTermsToMetadata({ path: target.path }) });
}
return target.path.slice(baseUrl.length - 1);
}

View File

@@ -253,10 +253,17 @@ export const SOLID_AS = createVocabulary('urn:npm:solid:community-server:activit
export const SOLID_ERROR = createVocabulary('urn:npm:solid:community-server:error:',
'disallowedMethod',
'errorCode',
'errorResponse',
'stack',
);
// Used to pass parameters to error templates
export const SOLID_ERROR_TERM = createVocabulary('urn:npm:solid:community-server:error-term:',
// Identifier of the resource responsible for the error
'path',
);
export const SOLID_HTTP = createVocabulary('urn:npm:solid:community-server:http:',
'location',
'slug',

View File

@@ -12,15 +12,8 @@ export function isError(error: any): error is Error {
}
/**
* Asserts that the input is a native error.
* If not the input will be re-thrown.
* Creates a string representing the error message of this object.
*/
export function assertError(error: unknown): asserts error is Error {
if (!isError(error)) {
throw error;
}
}
export function createErrorMessage(error: unknown): string {
return isError(error) ? error.message : `Unknown error: ${error}`;
}

View File

@@ -1,14 +1,13 @@
import { DataFactory } from 'n3';
import type { NamedNode, Quad, Quad_Subject } from 'rdf-js';
import { toNamedTerm } from '../TermUtil';
import { SOLID_ERROR } from '../Vocabularies';
import type { NamedNode } from 'rdf-js';
import { RepresentationMetadata } from '../../http/representation/RepresentationMetadata';
import { toLiteral, toNamedTerm } from '../TermUtil';
import { HTTP, SOLID_ERROR, XSD } from '../Vocabularies';
import { isError } from './ErrorUtil';
import quad = DataFactory.quad;
export interface HttpErrorOptions {
cause?: unknown;
errorCode?: string;
details?: NodeJS.Dict<unknown>;
metadata?: RepresentationMetadata;
}
/**
@@ -26,7 +25,7 @@ export class HttpError<T extends number = number> extends Error implements HttpE
public readonly statusCode: T;
public readonly cause?: unknown;
public readonly errorCode: string;
public readonly details?: NodeJS.Dict<unknown>;
public readonly metadata: RepresentationMetadata;
/**
* Creates a new HTTP error. Subclasses should call this with their fixed status code.
@@ -41,23 +40,20 @@ export class HttpError<T extends number = number> extends Error implements HttpE
this.name = name;
this.cause = options.cause;
this.errorCode = options.errorCode ?? `H${statusCode}`;
this.details = options.details;
this.metadata = options.metadata ?? new RepresentationMetadata();
this.generateMetadata();
}
public static isInstance(error: any): error is HttpError {
return isError(error) && typeof (error as any).statusCode === 'number';
return isError(error) && typeof (error as any).statusCode === 'number' && (error as any).metadata;
}
/**
* Returns quads representing metadata relevant to this error.
* Initializes the error metadata.
*/
public generateMetadata(subject: Quad_Subject | string): Quad[] {
// The reason we have this here instead of the generate function below
// is because we still want errors created with `new HttpError` to be treated identical
// as errors created with the constructor of the error class corresponding to that specific status code.
return [
quad(toNamedTerm(subject), SOLID_ERROR.terms.errorResponse, generateHttpErrorUri(this.statusCode)),
];
protected generateMetadata(): void {
this.metadata.add(SOLID_ERROR.terms.errorResponse, generateHttpErrorUri(this.statusCode));
this.metadata.add(HTTP.terms.statusCodeNumber, toLiteral(this.statusCode, XSD.terms.integer));
}
}

View File

@@ -1,24 +1,55 @@
import { RepresentationMetadata } from '../../http/representation/RepresentationMetadata';
import { toPredicateTerm } from '../TermUtil';
import { SOLID_ERROR_TERM } from '../Vocabularies';
import { BadRequestHttpError } from './BadRequestHttpError';
import { createErrorMessage } from './ErrorUtil';
import { HttpError } from './HttpError';
import { InternalServerError } from './InternalServerError';
import Dict = NodeJS.Dict;
/**
* Returns the HTTP status code corresponding to the error.
* Adds the given terms to error metadata.
* The keys will be converted to predicates by prepending them with the `SOLID_ERROR_TERM` namespace.
* The values will become literals.
*
* @param terms - Terms to add to the metadata.
* @param metadata - Metadata to add the terms to. A new metadata object will be created if this is undefined.
*/
export function getStatusCode(error: Error): number {
return HttpError.isInstance(error) ? error.statusCode : 500;
export function errorTermsToMetadata(terms: Dict<string>, metadata?: RepresentationMetadata): RepresentationMetadata {
metadata = metadata ?? new RepresentationMetadata();
for (const [ key, value ] of Object.entries(terms)) {
if (value) {
metadata.add(toPredicateTerm(`${SOLID_ERROR_TERM.namespace}${key}`), value);
}
}
return metadata;
}
/**
* Combines a list of errors into a single HttpErrors.
* Extracts all the error metadata terms and converts them to a simple object.
* All predicates in the `SOLID_ERROR_TERM` namespace will be found.
* The namespace will be removed from the predicate and the remainder will be used as a key.
* The object literal values will be used as values in the resulting object.
*
* @param metadata - Metadata to extract the terms from.
*/
export function extractErrorTerms(metadata: RepresentationMetadata): Dict<string> {
return metadata.quads()
.filter((quad): boolean => quad.predicate.value.startsWith(SOLID_ERROR_TERM.namespace))
.reduce<NodeJS.Dict<string>>((acc, quad): Dict<string> => {
acc[quad.predicate.value.slice(SOLID_ERROR_TERM.namespace.length)] = quad.object.value;
return acc;
}, {});
}
/**
* Combines a list of errors into a single HttpError.
* Status code depends on the input errors. If they all share the same status code that code will be re-used.
* If they are all within the 4xx range, 400 will be used, otherwise 500.
*
* @param errors - Errors to combine.
*/
export function createAggregateError(errors: Error[]):
HttpError {
export function createAggregateError(errors: Error[]): HttpError {
const httpErrors = errors.map((error): HttpError =>
HttpError.isInstance(error) ? error : new InternalServerError(createErrorMessage(error)));
const messages = httpErrors.map((error: Error): string => error.message).filter((msg): boolean => msg.length > 0);

View File

@@ -1,10 +1,6 @@
import { DataFactory } from 'n3';
import type { Quad, Quad_Subject } from 'rdf-js';
import { toNamedTerm, toObjectTerm } from '../TermUtil';
import { SOLID_ERROR } from '../Vocabularies';
import type { HttpErrorOptions } from './HttpError';
import { generateHttpErrorClass } from './HttpError';
import quad = DataFactory.quad;
// eslint-disable-next-line @typescript-eslint/naming-convention
const BaseHttpError = generateHttpErrorClass(405, 'MethodNotAllowedHttpError');
@@ -18,15 +14,10 @@ export class MethodNotAllowedHttpError extends BaseHttpError {
public constructor(methods: string[] = [], message?: string, options?: HttpErrorOptions) {
super(message ?? `${methods} are not allowed.`, options);
// Can not override `generateMetadata` as `this.methods` is not defined yet
for (const method of methods) {
this.metadata.add(SOLID_ERROR.terms.disallowedMethod, method);
}
this.methods = methods;
}
public generateMetadata(subject: Quad_Subject | string): Quad[] {
const term = toNamedTerm(subject);
const quads = super.generateMetadata(term);
for (const method of this.methods) {
quads.push(quad(term, SOLID_ERROR.terms.disallowedMethod, toObjectTerm(method, true)));
}
return quads;
}
}

View File

@@ -1,5 +1,7 @@
import { DataFactory } from 'n3';
import { SOLID_HTTP } from '../Vocabularies';
import type { HttpErrorClass, HttpErrorOptions } from './HttpError';
import { generateHttpErrorClass, HttpError } from './HttpError';
import { generateHttpErrorUri, HttpError } from './HttpError';
/**
* An error corresponding to a 3xx status code.
@@ -11,6 +13,7 @@ export class RedirectHttpError<TCode extends number = number> extends HttpError<
public constructor(statusCode: TCode, name: string, location: string, message?: string, options?: HttpErrorOptions) {
super(statusCode, name, message, options);
this.location = location;
this.metadata.add(SOLID_HTTP.terms.location, DataFactory.namedNode(location));
}
public static isInstance(error: any): error is RedirectHttpError {
@@ -35,16 +38,12 @@ export function generateRedirectHttpErrorClass<TCode extends number>(
code: TCode,
name: string,
): RedirectHttpErrorClass<TCode> {
// eslint-disable-next-line @typescript-eslint/naming-convention
const BaseClass = generateHttpErrorClass(code, name);
// Need to extend `BaseClass` instead of `RedirectHttpError` to have the required static methods
return class SpecificRedirectHttpError extends BaseClass implements RedirectHttpError {
public readonly location: string;
return class SpecificRedirectHttpError extends RedirectHttpError<TCode> {
public static readonly statusCode = code;
public static readonly uri = generateHttpErrorUri(code);
public constructor(location: string, message?: string, options?: HttpErrorOptions) {
super(message, options);
this.location = location;
super(code, name, location, message, options);
}
public static isInstance(error: any): error is SpecificRedirectHttpError {

View File

@@ -1,5 +1,6 @@
import { URL } from 'url';
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import { errorTermsToMetadata } from '../errors/HttpErrorUtil';
import { InternalServerError } from '../errors/InternalServerError';
import { ensureTrailingSlash, isContainerIdentifier } from '../PathUtil';
import type { IdentifierStrategy } from './IdentifierStrategy';
@@ -18,7 +19,7 @@ export abstract class BaseIdentifierStrategy implements IdentifierStrategy {
public getParentContainer(identifier: ResourceIdentifier): ResourceIdentifier {
if (!this.supportsIdentifier(identifier)) {
throw new InternalServerError(`The identifier ${identifier.path} is outside the configured identifier space.`,
{ errorCode: 'E0001', details: { path: identifier.path }});
{ errorCode: 'E0001', metadata: errorTermsToMetadata({ path: identifier.path }) });
}
if (this.isRootContainer(identifier)) {
throw new InternalServerError(`Cannot obtain the parent of ${identifier.path} because it is a root container.`);