feat: Add utility functions for generating error classes

This commit is contained in:
Joachim Van Herwegen 2022-03-25 09:58:44 +01:00
parent 5d802c6a34
commit f3dedf4e27
19 changed files with 226 additions and 120 deletions

View File

@ -1,5 +1,6 @@
[
"Adapter",
"BaseHttpError",
"BasicConditions",
"BasicRepresentation",
"Error",

View File

@ -141,6 +141,7 @@ export const SOLID = createUriAndTermNamespace('http://www.w3.org/ns/solid/terms
);
export const SOLID_ERROR = createUriAndTermNamespace('urn:npm:solid:community-server:error:',
'errorResponse',
'stack',
);

View File

@ -1,24 +1,20 @@
import type { HttpErrorOptions } from './HttpError';
import { HttpError } from './HttpError';
import { generateHttpErrorClass } from './HttpError';
// eslint-disable-next-line @typescript-eslint/naming-convention
const BaseHttpError = generateHttpErrorClass(400, 'BadRequestHttpError');
/**
* An error thrown when incoming data is not supported.
* Probably because an {@link AsyncHandler} returns false on the canHandle call.
*/
export class BadRequestHttpError extends HttpError {
export class BadRequestHttpError extends BaseHttpError {
/**
* Default message is 'The given input is not supported by the server configuration.'.
* @param message - Optional, more specific, message.
* @param options - Optional error options.
*/
public constructor(message?: string, options?: HttpErrorOptions) {
super(400,
'BadRequestHttpError',
message ?? 'The given input is not supported by the server configuration.',
options);
}
public static isInstance(error: any): error is BadRequestHttpError {
return HttpError.isInstance(error) && error.statusCode === 400;
super(message ?? 'The given input is not supported by the server configuration.', options);
}
}

View File

@ -1,14 +1,14 @@
import type { HttpErrorOptions } from './HttpError';
import { HttpError } from './HttpError';
import { generateHttpErrorClass } from './HttpError';
// eslint-disable-next-line @typescript-eslint/naming-convention
const BaseHttpError = generateHttpErrorClass(409, 'ConflictHttpError');
/**
* An error thrown when a request conflict with current state of the server.
*/
export class ConflictHttpError extends HttpError {
export class ConflictHttpError extends BaseHttpError {
public constructor(message?: string, options?: HttpErrorOptions) {
super(409, 'ConflictHttpError', message, options);
}
public static isInstance(error: any): error is ConflictHttpError {
return HttpError.isInstance(error) && error.statusCode === 409;
super(message, options);
}
}

View File

@ -1,15 +1,14 @@
import type { HttpErrorOptions } from './HttpError';
import { HttpError } from './HttpError';
import { generateHttpErrorClass } from './HttpError';
// eslint-disable-next-line @typescript-eslint/naming-convention
const BaseHttpError = generateHttpErrorClass(403, 'ForbiddenHttpError');
/**
* An error thrown when an agent is not allowed to access data.
*/
export class ForbiddenHttpError extends HttpError {
export class ForbiddenHttpError extends BaseHttpError {
public constructor(message?: string, options?: HttpErrorOptions) {
super(403, 'ForbiddenHttpError', message, options);
}
public static isInstance(error: any): error is ForbiddenHttpError {
return HttpError.isInstance(error) && error.statusCode === 403;
super(message, options);
}
}

View File

@ -1,15 +1,14 @@
import type { HttpErrorOptions } from './HttpError';
import { RedirectHttpError } from './RedirectHttpError';
import { generateRedirectHttpErrorClass } from './RedirectHttpError';
// eslint-disable-next-line @typescript-eslint/naming-convention
const BaseHttpError = generateRedirectHttpErrorClass(302, 'FoundHttpError');
/**
* Error used for resources that have been moved temporarily.
*/
export class FoundHttpError extends RedirectHttpError {
export class FoundHttpError extends BaseHttpError {
public constructor(location: string, message?: string, options?: HttpErrorOptions) {
super(302, location, 'FoundHttpError', message, options);
}
public static isInstance(error: any): error is FoundHttpError {
return RedirectHttpError.isInstance(error) && error.statusCode === 302;
super(location, message, options);
}
}

View File

@ -1,4 +1,9 @@
import { DataFactory } from 'n3';
import type { NamedNode, Quad, Quad_Subject } from 'rdf-js';
import { toNamedTerm } from '../TermUtil';
import { SOLID_ERROR } from '../Vocabularies';
import { isError } from './ErrorUtil';
import quad = DataFactory.quad;
export interface HttpErrorOptions {
cause?: unknown;
@ -6,13 +11,19 @@ export interface HttpErrorOptions {
details?: NodeJS.Dict<unknown>;
}
/**
* Returns a URI that is unique for the given status code.
*/
export function generateHttpErrorUri(statusCode: number): NamedNode {
return toNamedTerm(`${SOLID_ERROR.namespace}H${statusCode}`);
}
/**
* A class for all errors that could be thrown by Solid.
* All errors inheriting from this should fix the status code thereby hiding the HTTP internals from other components.
*/
export class HttpError extends Error implements HttpErrorOptions {
protected static readonly statusCode: number;
public readonly statusCode: number;
export class HttpError<T extends number = number> extends Error implements HttpErrorOptions {
public readonly statusCode: T;
public readonly cause?: unknown;
public readonly errorCode: string;
public readonly details?: NodeJS.Dict<unknown>;
@ -24,7 +35,7 @@ export class HttpError extends Error implements HttpErrorOptions {
* @param message - Error message.
* @param options - Optional options.
*/
public constructor(statusCode: number, name: string, message?: string, options: HttpErrorOptions = {}) {
public constructor(statusCode: T, name: string, message?: string, options: HttpErrorOptions = {}) {
super(message);
this.statusCode = statusCode;
this.name = name;
@ -36,4 +47,62 @@ export class HttpError extends Error implements HttpErrorOptions {
public static isInstance(error: any): error is HttpError {
return isError(error) && typeof (error as any).statusCode === 'number';
}
/**
* Returns quads representing metadata relevant to this error.
*/
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)),
];
}
}
/**
* Interface describing what an HttpError class should look like.
* This helps us make sure all HttpError classes have the same utility static functions.
*/
export interface HttpErrorClass<TCode extends number = number> {
new(message?: string, options?: HttpErrorOptions): HttpError<TCode>;
/**
* The status code corresponding to this error class.
*/
readonly statusCode: TCode;
/**
* A unique URI identifying this error class.
*/
readonly uri: NamedNode;
/**
* Checks if the given error is an instance of this class.
*/
readonly isInstance: (error: any) => error is HttpError<TCode>;
}
/**
* Generates a new HttpError class with the given status code and name.
* In general, status codes are used to uniquely identify error types,
* so there should be no 2 classes with the same value there.
*
* To make sure Components.js can work with these newly generated classes,
* the generated class should be called `BaseHttpError` as that name is an entry in `.componentsignore`.
* The actual class should then extend `BaseHttpError` and have a correct constructor,
* so the Components.js generator can generate the correct components JSON-LD file during build.
*/
export function generateHttpErrorClass<TCode extends number>(statusCode: TCode, name: string): HttpErrorClass<TCode> {
return class SpecificHttpError extends HttpError<TCode> {
public static readonly statusCode = statusCode;
public static readonly uri = generateHttpErrorUri(statusCode);
public constructor(message?: string, options?: HttpErrorOptions) {
super(statusCode, name, message, options);
}
public static isInstance(error: any): error is SpecificHttpError {
return HttpError.isInstance(error) && error.statusCode === statusCode;
}
};
}

View File

@ -1,14 +1,14 @@
import type { HttpErrorOptions } from './HttpError';
import { HttpError } from './HttpError';
import { generateHttpErrorClass } from './HttpError';
// eslint-disable-next-line @typescript-eslint/naming-convention
const BaseHttpError = generateHttpErrorClass(500, 'InternalServerError');
/**
* A generic error message, given when an unexpected condition was encountered and no more specific message is suitable.
*/
export class InternalServerError extends HttpError {
export class InternalServerError extends BaseHttpError {
public constructor(message?: string, options?: HttpErrorOptions) {
super(500, 'InternalServerError', message, options);
}
public static isInstance(error: any): error is InternalServerError {
return HttpError.isInstance(error) && error.statusCode === 500;
super(message, options);
}
}

View File

@ -1,15 +1,14 @@
import type { HttpErrorOptions } from './HttpError';
import { RedirectHttpError } from './RedirectHttpError';
import { generateRedirectHttpErrorClass } from './RedirectHttpError';
// eslint-disable-next-line @typescript-eslint/naming-convention
const BaseHttpError = generateRedirectHttpErrorClass(301, 'MovedPermanentlyHttpError');
/**
* Error used for resources that have been moved permanently.
*/
export class MovedPermanentlyHttpError extends RedirectHttpError {
export class MovedPermanentlyHttpError extends BaseHttpError {
public constructor(location: string, message?: string, options?: HttpErrorOptions) {
super(301, location, 'MovedPermanentlyHttpError', message, options);
}
public static isInstance(error: any): error is MovedPermanentlyHttpError {
return RedirectHttpError.isInstance(error) && error.statusCode === 301;
super(location, message, options);
}
}

View File

@ -1,14 +1,15 @@
import type { HttpErrorOptions } from './HttpError';
import { HttpError } from './HttpError';
import { generateHttpErrorClass } from './HttpError';
// eslint-disable-next-line @typescript-eslint/naming-convention
const BaseHttpError = generateHttpErrorClass(404, 'NotFoundHttpError');
/**
* An error thrown when no data was found for the requested identifier.
*/
export class NotFoundHttpError extends HttpError {
export class NotFoundHttpError extends BaseHttpError {
public constructor(message?: string, options?: HttpErrorOptions) {
super(404, 'NotFoundHttpError', message, options);
super(message, options);
}
}
public static isInstance(error: any): error is NotFoundHttpError {
return HttpError.isInstance(error) && error.statusCode === 404;
}
}

View File

@ -1,16 +1,15 @@
import type { HttpErrorOptions } from './HttpError';
import { HttpError } from './HttpError';
import { generateHttpErrorClass } from './HttpError';
// eslint-disable-next-line @typescript-eslint/naming-convention
const BaseHttpError = generateHttpErrorClass(501, 'NotImplementedHttpError');
/**
* The server either does not recognize the request method, or it lacks the ability to fulfil the request.
* Usually this implies future availability (e.g., a new feature of a web-service API).
*/
export class NotImplementedHttpError extends HttpError {
export class NotImplementedHttpError extends BaseHttpError {
public constructor(message?: string, options?: HttpErrorOptions) {
super(501, 'NotImplementedHttpError', message, options);
}
public static isInstance(error: any): error is NotImplementedHttpError {
return HttpError.isInstance(error) && error.statusCode === 501;
super(message, options);
}
}

View File

@ -1,23 +1,19 @@
import type { HttpErrorOptions } from './HttpError';
import { HttpError } from './HttpError';
import { generateHttpErrorClass } from './HttpError';
// eslint-disable-next-line @typescript-eslint/naming-convention
const BaseHttpError = generateHttpErrorClass(413, 'PayloadHttpError');
/**
* An error thrown when data exceeded the preconfigured quota
*/
export class PayloadHttpError extends HttpError {
export class PayloadHttpError extends BaseHttpError {
/**
* Default message is 'Storage quota was exceeded.'.
* @param message - Optional, more specific, message.
* @param options - Optional error options.
*/
public constructor(message?: string, options?: HttpErrorOptions) {
super(413,
'PayloadHttpError',
message ?? 'Storage quota was exceeded.',
options);
}
public static isInstance(error: any): error is PayloadHttpError {
return HttpError.isInstance(error) && error.statusCode === 413;
super(message ?? 'Storage quota was exceeded.', options);
}
}

View File

@ -1,15 +1,14 @@
import type { HttpErrorOptions } from './HttpError';
import { HttpError } from './HttpError';
import { generateHttpErrorClass } from './HttpError';
// eslint-disable-next-line @typescript-eslint/naming-convention
const BaseHttpError = generateHttpErrorClass(412, 'PreconditionFailedHttpError');
/**
* An error thrown when access was denied due to the conditions on the request.
*/
export class PreconditionFailedHttpError extends HttpError {
export class PreconditionFailedHttpError extends BaseHttpError {
public constructor(message?: string, options?: HttpErrorOptions) {
super(412, 'PreconditionFailedHttpError', message, options);
}
public static isInstance(error: any): error is PreconditionFailedHttpError {
return HttpError.isInstance(error) && error.statusCode === 412;
super(message, options);
}
}

View File

@ -1,14 +1,14 @@
import type { HttpErrorOptions } from './HttpError';
import { HttpError } from './HttpError';
import type { HttpErrorClass, HttpErrorOptions } from './HttpError';
import { generateHttpErrorClass, HttpError } from './HttpError';
/**
* Abstract class representing a 3xx redirect.
* An error corresponding to a 3xx status code.
* Includes the location it redirects to.
*/
export abstract class RedirectHttpError extends HttpError {
export class RedirectHttpError<TCode extends number = number> extends HttpError<TCode> {
public readonly location: string;
protected constructor(statusCode: number, location: string, name: string, message?: string,
options?: HttpErrorOptions) {
public constructor(statusCode: TCode, name: string, location: string, message?: string, options?: HttpErrorOptions) {
super(statusCode, name, message, options);
this.location = location;
}
@ -17,3 +17,38 @@ export abstract class RedirectHttpError extends HttpError {
return HttpError.isInstance(error) && typeof (error as any).location === 'string';
}
}
/**
* Interface describing what a {@link RedirectHttpError} class should look like.
* Makes sure a `location` value is always needed.
*/
export interface RedirectHttpErrorClass<TCode extends number = number> extends Omit<HttpErrorClass<TCode>, 'new'> {
new(location: string, message?: string, options?: HttpErrorOptions): RedirectHttpError<TCode>;
}
/**
* Generates a {@link RedirectHttpErrorClass}, similar to how {@link generateHttpErrorClass} works.
* The difference is that here a `location` field also gets set and the `getInstance` method
* also uses the {@link RedirectHttpError.isInstance} function.
*/
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;
public constructor(location: string, message?: string, options?: HttpErrorOptions) {
super(message, options);
this.location = location;
}
public static isInstance(error: any): error is SpecificRedirectHttpError {
return RedirectHttpError.isInstance(error) && error.statusCode === code;
}
};
}

View File

@ -1,15 +1,14 @@
import type { HttpErrorOptions } from './HttpError';
import { HttpError } from './HttpError';
import { generateHttpErrorClass } from './HttpError';
// eslint-disable-next-line @typescript-eslint/naming-convention
const BaseHttpError = generateHttpErrorClass(401, 'UnauthorizedHttpError');
/**
* An error thrown when an agent is not authorized.
*/
export class UnauthorizedHttpError extends HttpError {
export class UnauthorizedHttpError extends BaseHttpError {
public constructor(message?: string, options?: HttpErrorOptions) {
super(401, 'UnauthorizedHttpError', message, options);
}
public static isInstance(error: any): error is UnauthorizedHttpError {
return HttpError.isInstance(error) && error.statusCode === 401;
super(message, options);
}
}

View File

@ -1,15 +1,14 @@
import type { HttpErrorOptions } from './HttpError';
import { HttpError } from './HttpError';
import { generateHttpErrorClass } from './HttpError';
// eslint-disable-next-line @typescript-eslint/naming-convention
const BaseHttpError = generateHttpErrorClass(422, 'UnprocessableEntityHttpError');
/**
* An error thrown when the server understands the content-type but can't process the instructions.
*/
export class UnprocessableEntityHttpError extends HttpError {
export class UnprocessableEntityHttpError extends BaseHttpError {
public constructor(message?: string, options?: HttpErrorOptions) {
super(422, 'UnprocessableEntityHttpError', message, options);
}
public static isInstance(error: any): error is UnprocessableEntityHttpError {
return HttpError.isInstance(error) && error.statusCode === 422;
super(message, options);
}
}

View File

@ -1,15 +1,14 @@
import type { HttpErrorOptions } from './HttpError';
import { HttpError } from './HttpError';
import { generateHttpErrorClass } from './HttpError';
// eslint-disable-next-line @typescript-eslint/naming-convention
const BaseHttpError = generateHttpErrorClass(415, 'UnsupportedMediaTypeHttpError');
/**
* An error thrown when the media type of incoming data is not supported by a parser.
*/
export class UnsupportedMediaTypeHttpError extends HttpError {
export class UnsupportedMediaTypeHttpError extends BaseHttpError {
public constructor(message?: string, options?: HttpErrorOptions) {
super(415, 'UnsupportedMediaTypeHttpError', message, options);
}
public static isInstance(error: any): error is UnsupportedMediaTypeHttpError {
return HttpError.isInstance(error) && error.statusCode === 415;
super(message, options);
}
}

View File

@ -1,8 +1,10 @@
import 'jest-rdf';
import { DataFactory } from 'n3';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { ConflictHttpError } from '../../../../src/util/errors/ConflictHttpError';
import { ForbiddenHttpError } from '../../../../src/util/errors/ForbiddenHttpError';
import type { HttpErrorOptions } from '../../../../src/util/errors/HttpError';
import { HttpError } from '../../../../src/util/errors/HttpError';
import { generateHttpErrorUri } from '../../../../src/util/errors/HttpError';
import type { HttpErrorClass } from '../../../../src/util/errors/HttpError';
import { InternalServerError } from '../../../../src/util/errors/InternalServerError';
import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError';
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
@ -12,16 +14,11 @@ 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';
// Only used to make typings easier in the tests
class FixedHttpError extends HttpError {
public constructor(message?: string, options?: HttpErrorOptions) {
super(0, '', message, options);
}
}
import { SOLID_ERROR } from '../../../../src/util/Vocabularies';
const { literal, namedNode, quad } = DataFactory;
describe('HttpError', (): void => {
const errors: [string, number, typeof FixedHttpError][] = [
const errors: [string, number, HttpErrorClass][] = [
[ 'BadRequestHttpError', 400, BadRequestHttpError ],
[ 'UnauthorizedHttpError', 401, UnauthorizedHttpError ],
[ 'ForbiddenHttpError', 403, ForbiddenHttpError ],
@ -48,6 +45,10 @@ describe('HttpError', (): void => {
expect(constructor.isInstance(instance)).toBeTruthy();
});
it('has a URI.', (): void => {
expect(constructor.uri).toEqualRdfTerm(generateHttpErrorUri(statusCode));
});
it(`has name ${name}.`, (): void => {
expect(instance.name).toBe(name);
});
@ -75,5 +76,12 @@ describe('HttpError', (): void => {
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),
]);
});
});
});

View File

@ -1,16 +1,23 @@
import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError';
import type { HttpErrorOptions } from '../../../../src/util/errors/HttpError';
import { generateHttpErrorUri } from '../../../../src/util/errors/HttpError';
import { MovedPermanentlyHttpError } from '../../../../src/util/errors/MovedPermanentlyHttpError';
import { RedirectHttpError } from '../../../../src/util/errors/RedirectHttpError';
import type { RedirectHttpErrorClass } from '../../../../src/util/errors/RedirectHttpError';
// Used to make sure the RedirectHttpError constructor also gets called in a test.
class FixedRedirectHttpError extends RedirectHttpError {
public static readonly statusCode = 0;
public static readonly uri = generateHttpErrorUri(0);
public constructor(location: string, message?: string, options?: HttpErrorOptions) {
super(0, location, '', message, options);
super(0, 'RedirectHttpError', location, message, options);
}
}
describe('RedirectHttpError', (): void => {
const errors: [string, number, typeof FixedRedirectHttpError][] = [
const errors: [string, number, RedirectHttpErrorClass][] = [
[ 'RedirectHttpError', 0, FixedRedirectHttpError ],
[ 'MovedPermanentlyHttpError', 301, MovedPermanentlyHttpError ],
[ 'FoundHttpError', 302, FoundHttpError ],
];