diff --git a/packages/event-handler/src/rest/constants.ts b/packages/event-handler/src/rest/constants.ts index 2f4abbd868..c319f88500 100644 --- a/packages/event-handler/src/rest/constants.ts +++ b/packages/event-handler/src/rest/constants.ts @@ -10,6 +10,80 @@ export const HttpVerbs = { OPTIONS: 'OPTIONS', } as const; +export const HttpErrorCodes = { + // 1xx Informational + CONTINUE: 100, + SWITCHING_PROTOCOLS: 101, + PROCESSING: 102, + EARLY_HINTS: 103, + + // 2xx Success + OK: 200, + CREATED: 201, + ACCEPTED: 202, + NON_AUTHORITATIVE_INFORMATION: 203, + NO_CONTENT: 204, + RESET_CONTENT: 205, + PARTIAL_CONTENT: 206, + MULTI_STATUS: 207, + ALREADY_REPORTED: 208, + IM_USED: 226, + + // 3xx Redirection + MULTIPLE_CHOICES: 300, + MOVED_PERMANENTLY: 301, + FOUND: 302, + SEE_OTHER: 303, + NOT_MODIFIED: 304, + USE_PROXY: 305, + TEMPORARY_REDIRECT: 307, + PERMANENT_REDIRECT: 308, + + // 4xx Client Error + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + PAYMENT_REQUIRED: 402, + FORBIDDEN: 403, + NOT_FOUND: 404, + METHOD_NOT_ALLOWED: 405, + NOT_ACCEPTABLE: 406, + PROXY_AUTHENTICATION_REQUIRED: 407, + REQUEST_TIMEOUT: 408, + CONFLICT: 409, + GONE: 410, + LENGTH_REQUIRED: 411, + PRECONDITION_FAILED: 412, + REQUEST_ENTITY_TOO_LARGE: 413, + REQUEST_URI_TOO_LONG: 414, + UNSUPPORTED_MEDIA_TYPE: 415, + REQUESTED_RANGE_NOT_SATISFIABLE: 416, + EXPECTATION_FAILED: 417, + IM_A_TEAPOT: 418, + MISDIRECTED_REQUEST: 421, + UNPROCESSABLE_ENTITY: 422, + LOCKED: 423, + FAILED_DEPENDENCY: 424, + TOO_EARLY: 425, + UPGRADE_REQUIRED: 426, + PRECONDITION_REQUIRED: 428, + TOO_MANY_REQUESTS: 429, + REQUEST_HEADER_FIELDS_TOO_LARGE: 431, + UNAVAILABLE_FOR_LEGAL_REASONS: 451, + + // 5xx Server Error + INTERNAL_SERVER_ERROR: 500, + NOT_IMPLEMENTED: 501, + BAD_GATEWAY: 502, + SERVICE_UNAVAILABLE: 503, + GATEWAY_TIMEOUT: 504, + HTTP_VERSION_NOT_SUPPORTED: 505, + VARIANT_ALSO_NEGOTIATES: 506, + INSUFFICIENT_STORAGE: 507, + LOOP_DETECTED: 508, + NOT_EXTENDED: 510, + NETWORK_AUTHENTICATION_REQUIRED: 511, +} as const; + export const PARAM_PATTERN = /:([a-zA-Z_]\w*)(?=\/|$)/g; export const SAFE_CHARS = "-._~()'!*:@,;=+&$"; diff --git a/packages/event-handler/src/rest/errors.ts b/packages/event-handler/src/rest/errors.ts index f72339d386..426566d088 100644 --- a/packages/event-handler/src/rest/errors.ts +++ b/packages/event-handler/src/rest/errors.ts @@ -1,3 +1,6 @@ +import type { ErrorResponse, HttpStatusCode } from '../types/rest.js'; +import { HttpErrorCodes } from './constants.js'; + export class RouteMatchingError extends Error { constructor( message: string, @@ -15,3 +18,145 @@ export class ParameterValidationError extends RouteMatchingError { this.name = 'ParameterValidationError'; } } + +abstract class ServiceError extends Error { + abstract readonly statusCode: HttpStatusCode; + abstract readonly errorType: string; + public readonly details?: Record; + + constructor( + message?: string, + options?: ErrorOptions, + details?: Record + ) { + super(message, options); + this.name = 'ServiceError'; + this.details = details; + } + + toJSON(): ErrorResponse { + return { + statusCode: this.statusCode, + error: this.errorType, + message: this.message, + ...(this.details && { details: this.details }), + }; + } +} + +export class BadRequestError extends ServiceError { + readonly statusCode = HttpErrorCodes.BAD_REQUEST; + readonly errorType = 'BadRequestError'; + + constructor( + message?: string, + options?: ErrorOptions, + details?: Record + ) { + super(message, options, details); + } +} + +export class UnauthorizedError extends ServiceError { + readonly statusCode = HttpErrorCodes.UNAUTHORIZED; + readonly errorType = 'UnauthorizedError'; + + constructor( + message?: string, + options?: ErrorOptions, + details?: Record + ) { + super(message, options, details); + } +} + +export class ForbiddenError extends ServiceError { + readonly statusCode = HttpErrorCodes.FORBIDDEN; + readonly errorType = 'ForbiddenError'; + + constructor( + message?: string, + options?: ErrorOptions, + details?: Record + ) { + super(message, options, details); + } +} + +export class NotFoundError extends ServiceError { + readonly statusCode = HttpErrorCodes.NOT_FOUND; + readonly errorType = 'NotFoundError'; + + constructor( + message?: string, + options?: ErrorOptions, + details?: Record + ) { + super(message, options, details); + } +} + +export class MethodNotAllowedError extends ServiceError { + readonly statusCode = HttpErrorCodes.METHOD_NOT_ALLOWED; + readonly errorType = 'MethodNotAllowedError'; + + constructor( + message?: string, + options?: ErrorOptions, + details?: Record + ) { + super(message, options, details); + } +} + +export class RequestTimeoutError extends ServiceError { + readonly statusCode = HttpErrorCodes.REQUEST_TIMEOUT; + readonly errorType = 'RequestTimeoutError'; + + constructor( + message?: string, + options?: ErrorOptions, + details?: Record + ) { + super(message, options, details); + } +} + +export class RequestEntityTooLargeError extends ServiceError { + readonly statusCode = HttpErrorCodes.REQUEST_ENTITY_TOO_LARGE; + readonly errorType = 'RequestEntityTooLargeError'; + + constructor( + message?: string, + options?: ErrorOptions, + details?: Record + ) { + super(message, options, details); + } +} + +export class InternalServerError extends ServiceError { + readonly statusCode = HttpErrorCodes.INTERNAL_SERVER_ERROR; + readonly errorType = 'InternalServerError'; + + constructor( + message?: string, + options?: ErrorOptions, + details?: Record + ) { + super(message, options, details); + } +} + +export class ServiceUnavailableError extends ServiceError { + readonly statusCode = HttpErrorCodes.SERVICE_UNAVAILABLE; + readonly errorType = 'ServiceUnavailableError'; + + constructor( + message?: string, + options?: ErrorOptions, + details?: Record + ) { + super(message, options, details); + } +} diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index 9665d2f08e..702862dd62 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -1,8 +1,32 @@ import type { GenericLogger } from '@aws-lambda-powertools/commons/types'; import type { BaseRouter } from '../rest/BaseRouter.js'; -import type { HttpVerbs } from '../rest/constants.js'; +import type { HttpErrorCodes, HttpVerbs } from '../rest/constants.js'; import type { Route } from '../rest/Route.js'; +type ErrorResponse = { + statusCode: HttpStatusCode; + error: string; + message: string; +}; + +interface ErrorContext { + path: string; + method: string; + headers: Record; + timestamp: string; + requestId?: string; +} + +type ErrorHandler = ( + error: T, + context?: ErrorContext +) => ErrorResponse; + +interface ErrorConstructor { + new (...args: any[]): T; + prototype: T; +} + /** * Options for the {@link BaseRouter} class */ @@ -29,6 +53,8 @@ type RouteHandler = (...args: T[]) => R; type HttpMethod = keyof typeof HttpVerbs; +type HttpStatusCode = (typeof HttpErrorCodes)[keyof typeof HttpErrorCodes]; + type Path = `/${string}`; type RouteHandlerOptions = { @@ -59,6 +85,10 @@ type ValidationResult = { export type { CompiledRoute, DynamicRoute, + ErrorResponse, + ErrorConstructor, + ErrorHandler, + HttpStatusCode, HttpMethod, Path, RouterOptions, diff --git a/packages/event-handler/tests/unit/rest/errors.test.ts b/packages/event-handler/tests/unit/rest/errors.test.ts new file mode 100644 index 0000000000..becacd2b80 --- /dev/null +++ b/packages/event-handler/tests/unit/rest/errors.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest'; +import { HttpErrorCodes } from '../../../src/rest/constants.js'; +import { + BadRequestError, + ForbiddenError, + InternalServerError, + MethodNotAllowedError, + NotFoundError, + RequestEntityTooLargeError, + RequestTimeoutError, + ServiceUnavailableError, + UnauthorizedError, +} from '../../../src/rest/errors.js'; + +describe('HTTP Error Classes', () => { + it.each([ + { + ErrorClass: BadRequestError, + errorType: 'BadRequestError', + statusCode: HttpErrorCodes.BAD_REQUEST, + customMessage: 'Invalid input', + }, + { + ErrorClass: UnauthorizedError, + errorType: 'UnauthorizedError', + statusCode: HttpErrorCodes.UNAUTHORIZED, + customMessage: 'Token expired', + }, + { + ErrorClass: ForbiddenError, + errorType: 'ForbiddenError', + statusCode: HttpErrorCodes.FORBIDDEN, + customMessage: 'Access denied', + }, + { + ErrorClass: NotFoundError, + errorType: 'NotFoundError', + statusCode: HttpErrorCodes.NOT_FOUND, + customMessage: 'Resource not found', + }, + { + ErrorClass: MethodNotAllowedError, + errorType: 'MethodNotAllowedError', + statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED, + customMessage: 'POST not allowed', + }, + { + ErrorClass: RequestTimeoutError, + errorType: 'RequestTimeoutError', + statusCode: HttpErrorCodes.REQUEST_TIMEOUT, + customMessage: 'Operation timed out', + }, + { + ErrorClass: RequestEntityTooLargeError, + errorType: 'RequestEntityTooLargeError', + statusCode: HttpErrorCodes.REQUEST_ENTITY_TOO_LARGE, + customMessage: 'File too large', + }, + { + ErrorClass: InternalServerError, + errorType: 'InternalServerError', + statusCode: HttpErrorCodes.INTERNAL_SERVER_ERROR, + customMessage: 'Database connection failed', + }, + { + ErrorClass: ServiceUnavailableError, + errorType: 'ServiceUnavailableError', + statusCode: HttpErrorCodes.SERVICE_UNAVAILABLE, + customMessage: 'Maintenance mode', + }, + ])( + '$errorType uses custom message when provided', + ({ ErrorClass, errorType, statusCode, customMessage }) => { + const error = new ErrorClass(customMessage); + expect(error.message).toBe(customMessage); + expect(error.statusCode).toBe(statusCode); + expect(error.errorType).toBe(errorType); + } + ); + + describe('toJSON', () => { + it.each([ + { + ErrorClass: BadRequestError, + errorType: 'BadRequestError', + statusCode: HttpErrorCodes.BAD_REQUEST, + message: 'Invalid input', + }, + { + ErrorClass: UnauthorizedError, + errorType: 'UnauthorizedError', + statusCode: HttpErrorCodes.UNAUTHORIZED, + message: 'Token expired', + }, + { + ErrorClass: ForbiddenError, + errorType: 'ForbiddenError', + statusCode: HttpErrorCodes.FORBIDDEN, + message: 'Access denied', + }, + { + ErrorClass: NotFoundError, + errorType: 'NotFoundError', + statusCode: HttpErrorCodes.NOT_FOUND, + message: 'Resource not found', + }, + { + ErrorClass: MethodNotAllowedError, + errorType: 'MethodNotAllowedError', + statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED, + message: 'POST not allowed', + }, + { + ErrorClass: RequestTimeoutError, + errorType: 'RequestTimeoutError', + statusCode: HttpErrorCodes.REQUEST_TIMEOUT, + message: 'Operation timed out', + }, + { + ErrorClass: RequestEntityTooLargeError, + errorType: 'RequestEntityTooLargeError', + statusCode: HttpErrorCodes.REQUEST_ENTITY_TOO_LARGE, + message: 'File too large', + }, + { + ErrorClass: InternalServerError, + errorType: 'InternalServerError', + statusCode: HttpErrorCodes.INTERNAL_SERVER_ERROR, + message: 'Database connection failed', + }, + { + ErrorClass: ServiceUnavailableError, + errorType: 'ServiceUnavailableError', + statusCode: HttpErrorCodes.SERVICE_UNAVAILABLE, + message: 'Maintenance mode', + }, + ])( + '$errorType serializes to JSON format', + ({ ErrorClass, errorType, statusCode, message }) => { + const error = new ErrorClass(message); + const json = error.toJSON(); + + expect(json).toEqual({ + statusCode, + error: errorType, + message, + }); + } + ); + + it('includes details in JSON when provided', () => { + const details = { field: 'value', code: 'VALIDATION_ERROR' }; + const error = new BadRequestError('Invalid input', undefined, details); + const json = error.toJSON(); + + expect(json).toEqual({ + statusCode: HttpErrorCodes.BAD_REQUEST, + error: 'BadRequestError', + message: 'Invalid input', + details, + }); + }); + + it('excludes details from JSON when not provided', () => { + const error = new BadRequestError('Invalid input'); + const json = error.toJSON(); + + expect(json).toEqual({ + statusCode: HttpErrorCodes.BAD_REQUEST, + error: 'BadRequestError', + message: 'Invalid input', + }); + expect(json).not.toHaveProperty('details'); + }); + }); + + it('passes options to Error superclass', () => { + const cause = new Error('Root cause'); + const error = new BadRequestError('Invalid input', { cause }); + + expect(error.cause).toBe(cause); + }); +});