diff --git a/src/http/input/identifier/OriginalUrlExtractor.ts b/src/http/input/identifier/OriginalUrlExtractor.ts index 6fcf6ab90..6674fba62 100644 --- a/src/http/input/identifier/OriginalUrlExtractor.ts +++ b/src/http/input/identifier/OriginalUrlExtractor.ts @@ -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; diff --git a/src/http/input/metadata/ContentTypeParser.ts b/src/http/input/metadata/ContentTypeParser.ts index fddc713ed..15817d19c 100644 --- a/src/http/input/metadata/ContentTypeParser.ts +++ b/src/http/input/metadata/ContentTypeParser.ts @@ -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 { const contentType = input.request.headers['content-type']; if (contentType) { - input.metadata.contentType = contentType; + input.metadata.contentTypeObject = parseContentType(contentType); } } } diff --git a/src/http/input/preferences/AcceptPreferenceParser.ts b/src/http/input/preferences/AcceptPreferenceParser.ts index 90fcd8ee9..d41ce9fd5 100644 --- a/src/http/input/preferences/AcceptPreferenceParser.ts +++ b/src/http/input/preferences/AcceptPreferenceParser.ts @@ -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, diff --git a/src/http/output/error/ConvertingErrorHandler.ts b/src/http/output/error/ConvertingErrorHandler.ts index e3645a60f..1e45823c4 100644 --- a/src/http/output/error/ConvertingErrorHandler.ts +++ b/src/http/output/error/ConvertingErrorHandler.ts @@ -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 { - 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); - } } diff --git a/src/http/output/error/ErrorHandler.ts b/src/http/output/error/ErrorHandler.ts index c78ab7864..8d6f3b96e 100644 --- a/src/http/output/error/ErrorHandler.ts +++ b/src/http/output/error/ErrorHandler.ts @@ -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; } diff --git a/src/http/output/error/SafeErrorHandler.ts b/src/http/output/error/SafeErrorHandler.ts index 9da9b095d..aac839e97 100644 --- a/src/http/output/error/SafeErrorHandler.ts +++ b/src/http/output/error/SafeErrorHandler.ts @@ -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), }; } diff --git a/src/http/output/response/RedirectResponseDescription.ts b/src/http/output/response/RedirectResponseDescription.ts index 293fd3596..2615e774a 100644 --- a/src/http/output/response/RedirectResponseDescription.ts +++ b/src/http/output/response/RedirectResponseDescription.ts @@ -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); } } diff --git a/src/http/representation/RepresentationMetadata.ts b/src/http/representation/RepresentationMetadata.ts index 1d6019184..e11e5313f 100644 --- a/src/http/representation/RepresentationMetadata.ts +++ b/src/http/representation/RepresentationMetadata.ts @@ -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)) { diff --git a/src/identity/configuration/IdentityProviderFactory.ts b/src/identity/configuration/IdentityProviderFactory.ts index a075221a0..aaeba0893 100644 --- a/src/identity/configuration/IdentityProviderFactory.ts +++ b/src/identity/configuration/IdentityProviderFactory.ts @@ -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; diff --git a/src/identity/ownership/TokenOwnershipValidator.ts b/src/identity/ownership/TokenOwnershipValidator.ts index 3f0504d83..dd0191869 100644 --- a/src/identity/ownership/TokenOwnershipValidator.ts +++ b/src/identity/ownership/TokenOwnershipValidator.ts @@ -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) }); } } diff --git a/src/server/ParsingHttpHandler.ts b/src/server/ParsingHttpHandler.ts index 0c9c9b9ea..4c2e18438 100644 --- a/src/server/ParsingHttpHandler.ts +++ b/src/server/ParsingHttpHandler.ts @@ -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 { - 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 }); } } diff --git a/src/storage/conversion/ErrorToJsonConverter.ts b/src/storage/conversion/ErrorToJsonConverter.ts index 986a13888..00876c6e6 100644 --- a/src/storage/conversion/ErrorToJsonConverter.ts +++ b/src/storage/conversion/ErrorToJsonConverter.ts @@ -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 { - const error = await getSingleItem(representation.data) as Error; + const error = await getSingleItem(representation.data) as HttpError; const result: Record = { 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; } diff --git a/src/storage/conversion/ErrorToQuadConverter.ts b/src/storage/conversion/ErrorToQuadConverter.ts index 5ebdfb789..b3536ddc2 100644 --- a/src/storage/conversion/ErrorToQuadConverter.ts +++ b/src/storage/conversion/ErrorToQuadConverter.ts @@ -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 { - 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); diff --git a/src/storage/conversion/ErrorToTemplateConverter.ts b/src/storage/conversion/ErrorToTemplateConverter.ts index 563215bb9..31cf07d7e 100644 --- a/src/storage/conversion/ErrorToTemplateConverter.ts +++ b/src/storage/conversion/ErrorToTemplateConverter.ts @@ -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 { - 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 diff --git a/src/util/Header.ts b/src/util/Header.ts new file mode 100644 index 000000000..aee5ae6f3 --- /dev/null +++ b/src/util/Header.ts @@ -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; + /** + * Extension parameters. These are the parameters that came after the q value. + * Value will be an empty string if there was none. + */ + extension: Record; + }; +} + +/** + * 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 = {}) {} + + /** + * 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 { + /** 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; diff --git a/src/util/HeaderUtil.ts b/src/util/HeaderUtil.ts index 5845d765e..3a467b8cc 100644 --- a/src/util/HeaderUtil.ts +++ b/src/util/HeaderUtil.ts @@ -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 = new Map(); // BNF based on https://tools.ietf.org/html/rfc7231 // @@ -16,124 +24,6 @@ const authSchemeRegexCache: Map = 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; - /** - * Extension parameters. These are the parameters that came after the q value. - * Value will be an empty string if there was none. - */ - extension: Record; - }; -} - -/** - * 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 = {}) {} - - /** - * 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 { - /** 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 = {}; 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, 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 = new Map(); /** * Checks if the value of an HTTP Authorization header matches a specific scheme (e.g. Basic, Bearer, etc). * diff --git a/src/util/PathUtil.ts b/src/util/PathUtil.ts index f09a10dce..327336a30 100644 --- a/src/util/PathUtil.ts +++ b/src/util/PathUtil.ts @@ -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 { 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); } diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index 71f04ffa1..70e6e34db 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -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', diff --git a/src/util/errors/ErrorUtil.ts b/src/util/errors/ErrorUtil.ts index 405b29c80..8b44cec7f 100644 --- a/src/util/errors/ErrorUtil.ts +++ b/src/util/errors/ErrorUtil.ts @@ -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}`; } diff --git a/src/util/errors/HttpError.ts b/src/util/errors/HttpError.ts index 2a69169ef..b1d642e03 100644 --- a/src/util/errors/HttpError.ts +++ b/src/util/errors/HttpError.ts @@ -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; + metadata?: RepresentationMetadata; } /** @@ -26,7 +25,7 @@ export class HttpError extends Error implements HttpE public readonly statusCode: T; public readonly cause?: unknown; public readonly errorCode: string; - public readonly details?: NodeJS.Dict; + 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 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)); } } diff --git a/src/util/errors/HttpErrorUtil.ts b/src/util/errors/HttpErrorUtil.ts index da9b3e922..cf30c0869 100644 --- a/src/util/errors/HttpErrorUtil.ts +++ b/src/util/errors/HttpErrorUtil.ts @@ -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, 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 { + return metadata.quads() + .filter((quad): boolean => quad.predicate.value.startsWith(SOLID_ERROR_TERM.namespace)) + .reduce>((acc, quad): Dict => { + 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); diff --git a/src/util/errors/MethodNotAllowedHttpError.ts b/src/util/errors/MethodNotAllowedHttpError.ts index 48e668f53..e5f6fc07c 100644 --- a/src/util/errors/MethodNotAllowedHttpError.ts +++ b/src/util/errors/MethodNotAllowedHttpError.ts @@ -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; - } } diff --git a/src/util/errors/RedirectHttpError.ts b/src/util/errors/RedirectHttpError.ts index 2f23229d0..9c8e364bd 100644 --- a/src/util/errors/RedirectHttpError.ts +++ b/src/util/errors/RedirectHttpError.ts @@ -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 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( code: TCode, name: string, ): RedirectHttpErrorClass { - // 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 { + 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 { diff --git a/src/util/identifiers/BaseIdentifierStrategy.ts b/src/util/identifiers/BaseIdentifierStrategy.ts index c5c355ca9..ad4810921 100644 --- a/src/util/identifiers/BaseIdentifierStrategy.ts +++ b/src/util/identifiers/BaseIdentifierStrategy.ts @@ -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.`); diff --git a/test/unit/http/input/body/SparqlUpdateBodyParser.test.ts b/test/unit/http/input/body/SparqlUpdateBodyParser.test.ts index 1f1ac0eb1..83d4e723a 100644 --- a/test/unit/http/input/body/SparqlUpdateBodyParser.test.ts +++ b/test/unit/http/input/body/SparqlUpdateBodyParser.test.ts @@ -9,6 +9,7 @@ import { RepresentationMetadata } from '../../../../../src/http/representation/R import type { HttpRequest } from '../../../../../src/server/HttpRequest'; import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError'; import { UnsupportedMediaTypeHttpError } from '../../../../../src/util/errors/UnsupportedMediaTypeHttpError'; +import { ContentType } from '../../../../../src/util/Header'; import { guardedStreamFrom } from '../../../../../src/util/StreamUtil'; const { namedNode, quad } = DataFactory; @@ -24,11 +25,10 @@ describe('A SparqlUpdateBodyParser', (): void => { await expect(bodyParser.canHandle(input)).rejects.toThrow(UnsupportedMediaTypeHttpError); input.metadata.contentType = 'text/plain'; await expect(bodyParser.canHandle(input)).rejects.toThrow(UnsupportedMediaTypeHttpError); - input.metadata.contentType = 'application/sparql-update;charset=utf-8'; + const contentType = new ContentType('application/sparql-update'); + input.metadata.contentTypeObject = contentType; await expect(bodyParser.canHandle(input)).resolves.toBeUndefined(); - input.metadata.contentType = 'application/sparql-update ; foo=bar'; - await expect(bodyParser.canHandle(input)).resolves.toBeUndefined(); - input.metadata.contentType = 'application/sparql-update'; + contentType.parameters = { charset: 'utf-8' }; await expect(bodyParser.canHandle(input)).resolves.toBeUndefined(); }); diff --git a/test/unit/http/output/error/ConvertingErrorHandler.test.ts b/test/unit/http/output/error/ConvertingErrorHandler.test.ts index 8602455ad..fbc6a65ec 100644 --- a/test/unit/http/output/error/ConvertingErrorHandler.test.ts +++ b/test/unit/http/output/error/ConvertingErrorHandler.test.ts @@ -11,6 +11,7 @@ import type { RepresentationConverter, RepresentationConverterArgs, } from '../../../../../src/storage/conversion/RepresentationConverter'; +import type { HttpError } from '../../../../../src/util/errors/HttpError'; import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError'; import { HTTP, XSD } from '../../../../../src/util/Vocabularies'; import literal = DataFactory.literal; @@ -33,7 +34,7 @@ async function expectValidArgs(args: RepresentationConverterArgs, stack?: string describe('A ConvertingErrorHandler', (): void => { // The error object can get modified by the handler - let error: Error; + let error: HttpError; let stack: string | undefined; const request = {} as HttpRequest; let converter: jest.Mocked; diff --git a/test/unit/http/output/metadata/ContentTypeMetadataWriter.test.ts b/test/unit/http/output/metadata/ContentTypeMetadataWriter.test.ts index 0ab61c7a5..5631498c2 100644 --- a/test/unit/http/output/metadata/ContentTypeMetadataWriter.test.ts +++ b/test/unit/http/output/metadata/ContentTypeMetadataWriter.test.ts @@ -2,6 +2,7 @@ import { createResponse } from 'node-mocks-http'; import { ContentTypeMetadataWriter } from '../../../../../src/http/output/metadata/ContentTypeMetadataWriter'; import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata'; import type { HttpResponse } from '../../../../../src/server/HttpResponse'; +import { ContentType } from '../../../../../src/util/Header'; describe('A ContentTypeMetadataWriter', (): void => { const writer = new ContentTypeMetadataWriter(); @@ -18,18 +19,12 @@ describe('A ContentTypeMetadataWriter', (): void => { }); it('adds a Content-Type header with parameters if present.', async(): Promise => { - const metadata = new RepresentationMetadata('text/plain; charset=utf-8'); + const metadata = new RepresentationMetadata(new ContentType('text/plain', { charset: 'utf-8' })); await expect(writer.handle({ response, metadata })).resolves.toBeUndefined(); expect(response.getHeaders()).toEqual({ 'content-type': 'text/plain; charset=utf-8', }); - - const metadata2 = new RepresentationMetadata('text/plain; charset="utf-8"'); - await expect(writer.handle({ response, metadata: metadata2 })).resolves.toBeUndefined(); - expect(response.getHeaders()).toEqual({ - 'content-type': 'text/plain; charset=utf-8', - }); }); it('adds a Content-Type header without parameters.', async(): Promise => { diff --git a/test/unit/http/output/response/RedirectResponseDescription.test.ts b/test/unit/http/output/response/RedirectResponseDescription.test.ts index 2f9096abb..5a4ca4bfc 100644 --- a/test/unit/http/output/response/RedirectResponseDescription.test.ts +++ b/test/unit/http/output/response/RedirectResponseDescription.test.ts @@ -8,6 +8,7 @@ describe('A RedirectResponseDescription', (): void => { it('has status the code and location of the error.', async(): Promise => { const description = new RedirectResponseDescription(error); expect(description.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(error.location); + expect(description.metadata).toBe(error.metadata); expect(description.statusCode).toBe(error.statusCode); }); }); diff --git a/test/unit/http/representation/RepresentationMetadata.test.ts b/test/unit/http/representation/RepresentationMetadata.test.ts index d64e8f639..e106dc9b6 100644 --- a/test/unit/http/representation/RepresentationMetadata.test.ts +++ b/test/unit/http/representation/RepresentationMetadata.test.ts @@ -2,8 +2,8 @@ import 'jest-rdf'; import type { BlankNode } from 'n3'; import { DataFactory } from 'n3'; import type { NamedNode, Quad } from 'rdf-js'; -import { ContentType } from '../../../../src'; import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; +import { ContentType } from '../../../../src/util/Header'; import { CONTENT_TYPE_TERM, SOLID_META, RDFS } from '../../../../src/util/Vocabularies'; const { defaultGraph, literal, namedNode, quad } = DataFactory; @@ -308,14 +308,14 @@ describe('A RepresentationMetadata', (): void => { it('has a shorthand for Content-Type as string.', async(): Promise => { expect(metadata.contentType).toBeUndefined(); expect(metadata.contentTypeObject).toBeUndefined(); - metadata.contentType = 'text/plain; charset=utf-8; test=value1'; - expect(metadata.contentTypeObject).toEqual({ - value: 'text/plain', - parameters: { - charset: 'utf-8', - test: 'value1', - }, - }); + metadata.contentType = 'text/plain'; + expect(metadata.contentTypeObject).toEqual({ value: 'text/plain', parameters: {}}); + }); + + it('errors trying to set a Content-Type with parameters using a string.', async(): Promise => { + expect((): void => { + metadata.contentType = 'text/plain; charset=utf-8; test=value1'; + }).toThrow(Error); }); it('has a shorthand for Content-Type as object.', async(): Promise => { @@ -341,7 +341,10 @@ describe('A RepresentationMetadata', (): void => { it('can properly clear the Content-Type parameters explicitly.', async(): Promise => { expect(metadata.contentType).toBeUndefined(); expect(metadata.contentTypeObject).toBeUndefined(); - metadata.contentType = 'text/plain; charset=utf-8; test=value1'; + metadata.contentTypeObject = new ContentType('text/plain', { + charset: 'utf-8', + test: 'value1', + }); metadata.contentType = undefined; expect(metadata.contentType).toBeUndefined(); expect(metadata.contentTypeObject).toBeUndefined(); @@ -353,7 +356,10 @@ describe('A RepresentationMetadata', (): void => { it('can properly clear the Content-Type parameters implicitly.', async(): Promise => { expect(metadata.contentType).toBeUndefined(); expect(metadata.contentTypeObject).toBeUndefined(); - metadata.contentType = 'text/plain; charset=utf-8; test=value1'; + metadata.contentTypeObject = new ContentType('text/plain', { + charset: 'utf-8', + test: 'value1', + }); metadata.contentType = 'text/turtle'; expect(metadata.contentType).toBe('text/turtle'); expect(metadata.contentTypeObject).toEqual({ @@ -368,7 +374,10 @@ describe('A RepresentationMetadata', (): void => { it('can return invalid parameters when too many quads are present.', async(): Promise => { expect(metadata.contentType).toBeUndefined(); expect(metadata.contentTypeObject).toBeUndefined(); - metadata.contentType = 'text/plain; charset=utf-8; test=value1'; + metadata.contentTypeObject = new ContentType('text/plain', { + charset: 'utf-8', + test: 'value1', + }); const param = metadata.quads(null, SOLID_META.terms.value)[0].subject; metadata.addQuad(param as BlankNode, SOLID_META.terms.value, 'anomaly'); expect(metadata.contentTypeObject?.parameters).toMatchObject({ invalid: '' }); diff --git a/test/unit/identity/configuration/IdentityProviderFactory.test.ts b/test/unit/identity/configuration/IdentityProviderFactory.test.ts index bfaf2c377..1729503af 100644 --- a/test/unit/identity/configuration/IdentityProviderFactory.test.ts +++ b/test/unit/identity/configuration/IdentityProviderFactory.test.ts @@ -14,6 +14,7 @@ import type { Interaction, InteractionHandler } from '../../../../src/identity/i import type { AdapterFactory } from '../../../../src/identity/storage/AdapterFactory'; import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage'; import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError'; +import { extractErrorTerms } from '../../../../src/util/errors/HttpErrorUtil'; import { OAuthHttpError } from '../../../../src/util/errors/OAuthHttpError'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -269,12 +270,12 @@ describe('An IdentityProviderFactory', (): void => { name: 'BadRequestHttpError', message: 'Unknown client, you might need to clear the local storage on the client.', errorCode: 'E0003', - details: { - client_id: 'CLIENT_ID', - redirect_uri: 'REDIRECT_URI', - }, }), request: ctx.req }); + expect(extractErrorTerms(errorHandler.handleSafe.mock.calls[0][0].error.metadata)).toEqual({ + client_id: 'CLIENT_ID', + redirect_uri: 'REDIRECT_URI', + }); expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response: ctx.res, result: { statusCode: 500 }}); }); diff --git a/test/unit/identity/ownership/TokenOwnershipValidator.test.ts b/test/unit/identity/ownership/TokenOwnershipValidator.test.ts index 3c939c736..90b338134 100644 --- a/test/unit/identity/ownership/TokenOwnershipValidator.test.ts +++ b/test/unit/identity/ownership/TokenOwnershipValidator.test.ts @@ -5,6 +5,8 @@ import rdfDereferencer from 'rdf-dereference'; import { v4 } from 'uuid'; import { TokenOwnershipValidator } from '../../../../src/identity/ownership/TokenOwnershipValidator'; import type { ExpiringStorage } from '../../../../src/storage/keyvalue/ExpiringStorage'; +import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; +import { extractErrorTerms } from '../../../../src/util/errors/HttpErrorUtil'; import { SOLID } from '../../../../src/util/Vocabularies'; const { literal, namedNode, quad } = DataFactory; @@ -57,10 +59,16 @@ describe('A TokenOwnershipValidator', (): void => { it('errors if no token is stored in the storage.', async(): Promise => { // Even if the token is in the WebId, it will error since it's not in the storage mockDereference(tokenTriple); - await expect(validator.handle({ webId })).rejects.toThrow(expect.objectContaining({ - message: expect.stringContaining(tokenString), - details: { quad: tokenString }, - })); + let error: unknown; + try { + await validator.handle({ webId }); + } catch (err: unknown) { + error = err; + } + expect(error).toEqual(expect.objectContaining({ message: expect.stringContaining(tokenString) })); + expect(BadRequestHttpError.isInstance(error)).toBe(true); + expect(extractErrorTerms((error as BadRequestHttpError).metadata)) + .toEqual({ quad: tokenString }); expect(rdfDereferenceMock.dereference).toHaveBeenCalledTimes(0); }); diff --git a/test/unit/server/ParsingHttpHandler.test.ts b/test/unit/server/ParsingHttpHandler.test.ts index 5557e6328..5fba38e78 100644 --- a/test/unit/server/ParsingHttpHandler.test.ts +++ b/test/unit/server/ParsingHttpHandler.test.ts @@ -4,12 +4,11 @@ import type { ErrorHandler } from '../../../src/http/output/error/ErrorHandler'; import { ResponseDescription } from '../../../src/http/output/response/ResponseDescription'; import type { ResponseWriter } from '../../../src/http/output/ResponseWriter'; import { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation'; -import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata'; import type { HttpRequest } from '../../../src/server/HttpRequest'; import type { HttpResponse } from '../../../src/server/HttpResponse'; import type { OperationHttpHandler } from '../../../src/server/OperationHttpHandler'; import { ParsingHttpHandler } from '../../../src/server/ParsingHttpHandler'; -import { HttpError } from '../../../src/util/errors/HttpError'; +import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpError'; describe('A ParsingHttpHandler', (): void => { const request: HttpRequest = {} as any; @@ -57,7 +56,7 @@ describe('A ParsingHttpHandler', (): void => { }); it('calls the error handler if something goes wrong.', async(): Promise => { - const error = new Error('bad data'); + const error = new BadRequestHttpError('bad data'); source.handleSafe.mockRejectedValueOnce(error); await expect(handler.handle({ request, response })).resolves.toBeUndefined(); expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1); @@ -66,16 +65,14 @@ describe('A ParsingHttpHandler', (): void => { expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: errorResponse }); }); - it('adds error metadata if able.', async(): Promise => { - const error = new HttpError(0, 'error'); + it('creates an InternalServerError if th error was not an HttpError.', async(): Promise => { + const error = new Error('bad data'); source.handleSafe.mockRejectedValueOnce(error); - const metaResponse = new ResponseDescription(0, new RepresentationMetadata()); - errorHandler.handleSafe.mockResolvedValueOnce(metaResponse); await expect(handler.handle({ request, response })).resolves.toBeUndefined(); expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1); - expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, request }); + expect(errorHandler.handleSafe).toHaveBeenLastCalledWith(expect.objectContaining({ request })); + expect(errorHandler.handleSafe.mock.calls[0][0].error.cause).toBe(error); expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); - expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: metaResponse }); - expect(metaResponse.metadata?.quads()).toHaveLength(1); + expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: errorResponse }); }); }); diff --git a/test/unit/storage/DataAccessorBasedStore.test.ts b/test/unit/storage/DataAccessorBasedStore.test.ts index 60615b4b5..17a74d01b 100644 --- a/test/unit/storage/DataAccessorBasedStore.test.ts +++ b/test/unit/storage/DataAccessorBasedStore.test.ts @@ -20,6 +20,7 @@ import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; import { NotImplementedHttpError } from '../../../src/util/errors/NotImplementedHttpError'; import { PreconditionFailedHttpError } from '../../../src/util/errors/PreconditionFailedHttpError'; import type { Guarded } from '../../../src/util/GuardedStream'; +import { ContentType } from '../../../src/util/Header'; import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy'; import { trimTrailingSlashes } from '../../../src/util/PathUtil'; import { guardedStreamFrom } from '../../../src/util/StreamUtil'; @@ -673,7 +674,7 @@ describe('A DataAccessorBasedStore', (): void => { representation.metadata.add( SOLID_META.terms.preserve, namedNode(metaResourceID.path), SOLID_META.terms.ResponseMetadata, ); - representation.metadata.contentType = 'text/plain; charset=UTF-8'; + representation.metadata.contentTypeObject = new ContentType('text/plain', { charset: 'UTF-8' }); await store.setRepresentation(resourceID, representation); const { metadata } = accessor.data[resourceID.path]; expect(metadata.quads(null, RDF.terms.type)).toHaveLength(2); diff --git a/test/unit/storage/conversion/ErrorToJsonConverter.test.ts b/test/unit/storage/conversion/ErrorToJsonConverter.test.ts index 018d0398d..336545d05 100644 --- a/test/unit/storage/conversion/ErrorToJsonConverter.test.ts +++ b/test/unit/storage/conversion/ErrorToJsonConverter.test.ts @@ -1,6 +1,7 @@ import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import { ErrorToJsonConverter } from '../../../../src/storage/conversion/ErrorToJsonConverter'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; +import { errorTermsToMetadata } from '../../../../src/util/errors/HttpErrorUtil'; import type { OAuthErrorFields } from '../../../../src/util/errors/OAuthHttpError'; import { OAuthHttpError } from '../../../../src/util/errors/OAuthHttpError'; import { readJsonStream } from '../../../../src/util/StreamUtil'; @@ -28,11 +29,13 @@ describe('An ErrorToJsonConverter', (): void => { statusCode: 400, errorCode: 'H400', stack: error.stack, + details: {}, }); }); it('copies the HttpError details.', async(): Promise => { - const error = new BadRequestHttpError('error text', { details: { important: 'detail' }}); + const metadata = errorTermsToMetadata({ important: 'detail' }); + const error = new BadRequestHttpError('error text', { metadata }); const representation = new BasicRepresentation([ error ], 'internal/error', false); const prom = converter.handle({ identifier, representation, preferences }); await expect(prom).resolves.toBeDefined(); @@ -75,11 +78,13 @@ describe('An ErrorToJsonConverter', (): void => { error_description: 'error_description', scope: 'scope', state: 'state', + details: {}, }); }); - it('does not copy the details if they are not serializable.', async(): Promise => { - const error = new BadRequestHttpError('error text', { details: { object: BigInt(1) }}); + it('only adds stack if it is defined.', async(): Promise => { + const error = new BadRequestHttpError('error text'); + delete error.stack; const representation = new BasicRepresentation([ error ], 'internal/error', false); const prom = converter.handle({ identifier, representation, preferences }); await expect(prom).resolves.toBeDefined(); @@ -91,39 +96,7 @@ describe('An ErrorToJsonConverter', (): void => { message: 'error text', statusCode: 400, errorCode: 'H400', - stack: error.stack, - }); - }); - - it('defaults to status code 500 for non-HTTP errors.', async(): Promise => { - const error = new Error('error text'); - const representation = new BasicRepresentation([ error ], 'internal/error', false); - const prom = converter.handle({ identifier, representation, preferences }); - await expect(prom).resolves.toBeDefined(); - const result = await prom; - expect(result.binary).toBe(true); - expect(result.metadata.contentType).toBe('application/json'); - await expect(readJsonStream(result.data)).resolves.toEqual({ - name: 'Error', - message: 'error text', - statusCode: 500, - stack: error.stack, - }); - }); - - it('only adds stack if it is defined.', async(): Promise => { - const error = new Error('error text'); - delete error.stack; - const representation = new BasicRepresentation([ error ], 'internal/error', false); - const prom = converter.handle({ identifier, representation, preferences }); - await expect(prom).resolves.toBeDefined(); - const result = await prom; - expect(result.binary).toBe(true); - expect(result.metadata.contentType).toBe('application/json'); - await expect(readJsonStream(result.data)).resolves.toEqual({ - name: 'Error', - message: 'error text', - statusCode: 500, + details: {}, }); }); }); diff --git a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts index 3fb1bfb3e..130a298c5 100644 --- a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts +++ b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts @@ -1,6 +1,7 @@ import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import { ErrorToTemplateConverter } from '../../../../src/storage/conversion/ErrorToTemplateConverter'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; +import { errorTermsToMetadata } from '../../../../src/util/errors/HttpErrorUtil'; import { resolveModulePath } from '../../../../src/util/PathUtil'; import { readableToString } from '../../../../src/util/StreamUtil'; import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine'; @@ -91,7 +92,8 @@ describe('An ErrorToTemplateConverter', (): void => { }); it('adds additional information if an error code description is found.', async(): Promise => { - const error = new BadRequestHttpError('error text', { errorCode, details: { key: 'val' }}); + const metadata = errorTermsToMetadata({ key: 'val' }); + const error = new BadRequestHttpError('error text', { errorCode, metadata }); const representation = new BasicRepresentation([ error ], 'internal/error', false); const prom = converter.handle({ identifier, representation, preferences }); await expect(prom).resolves.toBeDefined(); @@ -154,8 +156,9 @@ describe('An ErrorToTemplateConverter', (): void => { }); it('has default template options.', async(): Promise => { + const metadata = errorTermsToMetadata({ key: 'val' }); converter = new ErrorToTemplateConverter(templateEngine); - const error = new BadRequestHttpError('error text', { errorCode, details: { key: 'val' }}); + const error = new BadRequestHttpError('error text', { errorCode, metadata }); const representation = new BasicRepresentation([ error ], 'internal/error', false); const prom = converter.handle({ identifier, representation, preferences }); await expect(prom).resolves.toBeDefined(); diff --git a/test/unit/util/HeaderUtil.test.ts b/test/unit/util/HeaderUtil.test.ts index 049d2ac05..a51c794a3 100644 --- a/test/unit/util/HeaderUtil.test.ts +++ b/test/unit/util/HeaderUtil.test.ts @@ -1,7 +1,7 @@ import type { HttpResponse } from '../../../src/server/HttpResponse'; import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpError'; -import { ContentType, - addHeader, +import { ContentType } from '../../../src/util/Header'; +import { addHeader, hasScheme, matchesAuthorizationScheme, parseAccept, diff --git a/test/unit/util/PathUtil.test.ts b/test/unit/util/PathUtil.test.ts index 01816bb76..40201577d 100644 --- a/test/unit/util/PathUtil.test.ts +++ b/test/unit/util/PathUtil.test.ts @@ -2,6 +2,8 @@ import { promises as fsPromises } from 'fs'; import type { TargetExtractor } from '../../../src/http/input/identifier/TargetExtractor'; import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier'; import type { HttpRequest } from '../../../src/server/HttpRequest'; +import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpError'; +import { extractErrorTerms } from '../../../src/util/errors/HttpErrorUtil'; import { absoluteFilePath, createSubdomainRegexp, @@ -218,8 +220,15 @@ describe('PathUtil', (): void => { it('errors if the target is outside of the server scope.', async(): Promise => { targetExtractor.handleSafe.mockResolvedValueOnce({ path: 'http://somewhere.else/resource' }); - await expect(getRelativeUrl(baseUrl, request, targetExtractor)).rejects - .toThrow(expect.objectContaining({ errorCode: 'E0001', details: { path: 'http://somewhere.else/resource' }})); + let error: unknown; + try { + await getRelativeUrl(baseUrl, request, targetExtractor); + } catch (err: unknown) { + error = err; + } + expect(error).toEqual(expect.objectContaining({ errorCode: 'E0001' })); + expect(BadRequestHttpError.isInstance(error)).toBe(true); + expect(extractErrorTerms((error as BadRequestHttpError).metadata)).toEqual({ path: 'http://somewhere.else/resource' }); }); }); diff --git a/test/unit/util/errors/ErrorUtil.test.ts b/test/unit/util/errors/ErrorUtil.test.ts index 49eaf7c36..4fe9aaac7 100644 --- a/test/unit/util/errors/ErrorUtil.test.ts +++ b/test/unit/util/errors/ErrorUtil.test.ts @@ -1,4 +1,4 @@ -import { assertError, createErrorMessage, isError } from '../../../../src/util/errors/ErrorUtil'; +import { createErrorMessage, isError } from '../../../../src/util/errors/ErrorUtil'; describe('ErrorUtil', (): void => { describe('#isError', (): void => { @@ -19,16 +19,6 @@ describe('ErrorUtil', (): void => { }); }); - describe('#assertError', (): void => { - it('returns undefined on native errors.', async(): Promise => { - expect(assertError(new Error('error'))).toBeUndefined(); - }); - - it('throws on other values.', async(): Promise => { - expect((): void => assertError('apple')).toThrow('apple'); - }); - }); - describe('#createErrorMessage', (): void => { it('returns the given message for normal Errors.', async(): Promise => { expect(createErrorMessage(new Error('error msg'))).toBe('error msg'); diff --git a/test/unit/util/errors/HttpError.test.ts b/test/unit/util/errors/HttpError.test.ts index 4b2726dd2..2bdfb75f7 100644 --- a/test/unit/util/errors/HttpError.test.ts +++ b/test/unit/util/errors/HttpError.test.ts @@ -1,5 +1,5 @@ import 'jest-rdf'; -import { DataFactory } from 'n3'; +import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; import { ConflictHttpError } from '../../../../src/util/errors/ConflictHttpError'; import { ForbiddenHttpError } from '../../../../src/util/errors/ForbiddenHttpError'; @@ -15,9 +15,7 @@ import { PreconditionFailedHttpError } from '../../../../src/util/errors/Precond import { UnauthorizedHttpError } from '../../../../src/util/errors/UnauthorizedHttpError'; import { UnprocessableEntityHttpError } from '../../../../src/util/errors/UnprocessableEntityHttpError'; import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError'; -import { SOLID_ERROR } from '../../../../src/util/Vocabularies'; - -const { literal, namedNode, quad } = DataFactory; +import { HTTP, SOLID_ERROR } from '../../../../src/util/Vocabularies'; describe('HttpError', (): void => { const errors: [string, number, HttpErrorClass][] = [ @@ -39,7 +37,7 @@ describe('HttpError', (): void => { const options = { cause: new Error('cause'), errorCode: 'E1234', - details: {}, + metadata: new RepresentationMetadata(), }; const instance = new constructor('my message', options); @@ -75,15 +73,11 @@ describe('HttpError', (): void => { expect(new constructor().errorCode).toBe(`H${statusCode}`); }); - it('sets the details.', (): void => { - expect(instance.details).toBe(options.details); - }); - - it('generates metadata.', (): void => { - const subject = namedNode('subject'); - expect(instance.generateMetadata(subject)).toBeRdfIsomorphic([ - quad(subject, SOLID_ERROR.terms.errorResponse, constructor.uri), - ]); + it('sets the metadata.', (): void => { + expect(instance.metadata).toBe(options.metadata); + expect(instance.metadata.get(SOLID_ERROR.terms.errorResponse)?.value) + .toBe(`${SOLID_ERROR.namespace}H${statusCode}`); + expect(instance.metadata.get(HTTP.terms.statusCodeNumber)?.value).toBe(`${statusCode}`); }); }); @@ -92,7 +86,6 @@ describe('HttpError', (): void => { const options = { cause: new Error('cause'), errorCode: 'E1234', - details: { some: 'detail' }, }; const instance = new MethodNotAllowedHttpError([ 'GET' ], 'my message', options); @@ -107,11 +100,10 @@ describe('HttpError', (): void => { expect(instance.errorCode).toBe(options.errorCode); expect(new MethodNotAllowedHttpError([ 'GET' ]).errorCode).toBe(`H${405}`); - const subject = namedNode('subject'); - expect(instance.generateMetadata(subject)).toBeRdfIsomorphic([ - quad(subject, SOLID_ERROR.terms.errorResponse, MethodNotAllowedHttpError.uri), - quad(subject, SOLID_ERROR.terms.disallowedMethod, literal('GET')), - ]); + expect(instance.metadata.get(SOLID_ERROR.terms.errorResponse)?.value) + .toBe(`${SOLID_ERROR.namespace}H405`); + expect(instance.metadata.get(HTTP.terms.statusCodeNumber)?.value).toBe('405'); + expect(instance.metadata.get(SOLID_ERROR.terms.disallowedMethod)?.value).toBe('GET'); }); }); }); diff --git a/test/unit/util/errors/HttpErrorUtil.test.ts b/test/unit/util/errors/HttpErrorUtil.test.ts index b5ab84663..66f749135 100644 --- a/test/unit/util/errors/HttpErrorUtil.test.ts +++ b/test/unit/util/errors/HttpErrorUtil.test.ts @@ -1,8 +1,53 @@ +import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; import { HttpError } from '../../../../src/util/errors/HttpError'; -import { createAggregateError, getStatusCode } from '../../../../src/util/errors/HttpErrorUtil'; -import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; +import { + createAggregateError, + errorTermsToMetadata, + extractErrorTerms, +} from '../../../../src/util/errors/HttpErrorUtil'; +import { toPredicateTerm } from '../../../../src/util/TermUtil'; + +describe('HttpErrorUtil', (): void => { + describe('#errorTermsToMetadata', (): void => { + it('creates a metadata object with the necessary triples.', async(): Promise => { + const metadata = errorTermsToMetadata({ + test: 'apple', + test2: 'pear', + not: undefined, + }); + expect(metadata.quads()).toHaveLength(2); + expect(metadata.get(toPredicateTerm('urn:npm:solid:community-server:error-term:test'))?.value).toBe('apple'); + expect(metadata.get(toPredicateTerm('urn:npm:solid:community-server:error-term:test2'))?.value).toBe('pear'); + }); + + it('can add the necessary triples to existing metadata.', async(): Promise => { + const metadata = new RepresentationMetadata(); + const response = errorTermsToMetadata({ + test: 'apple', + test2: 'pear', + not: undefined, + }, metadata); + expect(response).toBe(metadata); + expect(metadata.quads()).toHaveLength(2); + expect(metadata.get(toPredicateTerm('urn:npm:solid:community-server:error-term:test'))?.value).toBe('apple'); + expect(metadata.get(toPredicateTerm('urn:npm:solid:community-server:error-term:test2'))?.value).toBe('pear'); + }); + }); + + describe('#extractErrorTerms', (): void => { + it('returns an object describing the terms.', async(): Promise => { + const metadata = new RepresentationMetadata({ + 'urn:npm:solid:community-server:error-term:test': 'apple', + 'urn:npm:solid:community-server:error-term:test2': 'pear', + 'urn:npm:solid:community-server:other:test3': 'mango', + }); + expect(extractErrorTerms(metadata)).toEqual({ + test: 'apple', + test2: 'pear', + }); + }); + }); -describe('ErrorUtil', (): void => { describe('#createAggregateError', (): void => { const error401 = new HttpError(401, 'UnauthorizedHttpError'); const error415 = new HttpError(415, 'UnsupportedMediaTypeHttpError'); @@ -50,14 +95,4 @@ describe('ErrorUtil', (): void => { .toBe('Multiple handler errors: noStatusCode, noStatusCode'); }); }); - - describe('#getStatusCode', (): void => { - it('returns the corresponding status code for HttpErrors.', async(): Promise => { - expect(getStatusCode(new NotFoundHttpError())).toBe(404); - }); - - it('returns 500 for other errors.', async(): Promise => { - expect(getStatusCode(new Error('404'))).toBe(500); - }); - }); }); diff --git a/test/unit/util/errors/RedirectHttpError.test.ts b/test/unit/util/errors/RedirectHttpError.test.ts index 643e95dce..ef68840cc 100644 --- a/test/unit/util/errors/RedirectHttpError.test.ts +++ b/test/unit/util/errors/RedirectHttpError.test.ts @@ -7,6 +7,7 @@ import { RedirectHttpError } from '../../../../src/util/errors/RedirectHttpError import type { RedirectHttpErrorClass } from '../../../../src/util/errors/RedirectHttpError'; import { SeeOtherHttpError } from '../../../../src/util/errors/SeeOtherHttpError'; import { TemporaryRedirectHttpError } from '../../../../src/util/errors/TemporaryRedirectHttpError'; +import { HTTP, SOLID_ERROR, SOLID_HTTP } from '../../../../src/util/Vocabularies'; // Used to make sure the RedirectHttpError constructor also gets called in a test. class FixedRedirectHttpError extends RedirectHttpError { @@ -70,7 +71,10 @@ describe('RedirectHttpError', (): void => { }); it('sets the details.', (): void => { - expect(instance.details).toBe(options.details); + expect(instance.metadata.get(SOLID_ERROR.terms.errorResponse)?.value) + .toBe(`${SOLID_ERROR.namespace}H${statusCode}`); + expect(instance.metadata.get(HTTP.terms.statusCodeNumber)?.value).toBe(`${statusCode}`); + expect(instance.metadata.get(SOLID_HTTP.terms.location)?.value).toBe(location); }); }); }); diff --git a/test/unit/util/identifiers/BaseIdentifierStrategy.test.ts b/test/unit/util/identifiers/BaseIdentifierStrategy.test.ts index 7b6539dfe..020da9c5b 100644 --- a/test/unit/util/identifiers/BaseIdentifierStrategy.test.ts +++ b/test/unit/util/identifiers/BaseIdentifierStrategy.test.ts @@ -1,4 +1,6 @@ import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; +import { extractErrorTerms } from '../../../../src/util/errors/HttpErrorUtil'; +import { InternalServerError } from '../../../../src/util/errors/InternalServerError'; import { BaseIdentifierStrategy } from '../../../../src/util/identifiers/BaseIdentifierStrategy'; class DummyStrategy extends BaseIdentifierStrategy { @@ -21,10 +23,16 @@ describe('A BaseIdentifierStrategy', (): void => { }); it('errors when attempting to get the parent of an unsupported identifier.', async(): Promise => { - expect((): any => strategy.getParentContainer({ path: '/unsupported' })) - .toThrow('The identifier /unsupported is outside the configured identifier space.'); - expect((): any => strategy.getParentContainer({ path: '/unsupported' })) - .toThrow(expect.objectContaining({ errorCode: 'E0001', details: { path: '/unsupported' }})); + let error: unknown; + try { + strategy.getParentContainer({ path: '/unsupported' }); + } catch (err: unknown) { + error = err; + } + expect(error).toEqual(expect.objectContaining({ errorCode: 'E0001', + message: 'The identifier /unsupported is outside the configured identifier space.' })); + expect(InternalServerError.isInstance(error)).toBe(true); + expect(extractErrorTerms((error as InternalServerError).metadata)).toEqual({ path: '/unsupported' }); }); it('errors when attempting to get the parent of a root container.', async(): Promise => {