diff --git a/packages/event-handler/src/rest/BaseRouter.ts b/packages/event-handler/src/rest/BaseRouter.ts index b3eb854166..209bdba9e2 100644 --- a/packages/event-handler/src/rest/BaseRouter.ts +++ b/packages/event-handler/src/rest/BaseRouter.ts @@ -6,6 +6,8 @@ import { import type { Context } from 'aws-lambda'; import type { ResolveOptions } from '../types/index.js'; import type { + ErrorConstructor, + ErrorHandler, HttpMethod, Path, RouteHandler, @@ -13,13 +15,15 @@ import type { RouterOptions, } from '../types/rest.js'; import { HttpVerbs } from './constants.js'; +import { ErrorHandlerRegistry } from './ErrorHandlerRegistry.js'; import { Route } from './Route.js'; import { RouteHandlerRegistry } from './RouteHandlerRegistry.js'; abstract class BaseRouter { protected context: Record; - protected routeRegistry: RouteHandlerRegistry; + protected readonly routeRegistry: RouteHandlerRegistry; + protected readonly errorHandlerRegistry: ErrorHandlerRegistry; /** * A logger instance to be used for logging debug, warning, and error messages. @@ -32,7 +36,7 @@ abstract class BaseRouter { */ protected readonly isDev: boolean = false; - public constructor(options?: RouterOptions) { + protected constructor(options?: RouterOptions) { this.context = {}; const alcLogLevel = getStringFromEnv({ key: 'AWS_LAMBDA_LOG_LEVEL', @@ -44,9 +48,19 @@ abstract class BaseRouter { warn: console.warn, }; this.routeRegistry = new RouteHandlerRegistry({ logger: this.logger }); + this.errorHandlerRegistry = new ErrorHandlerRegistry({ + logger: this.logger, + }); this.isDev = isDevMode(); } + public errorHandler( + errorType: ErrorConstructor | ErrorConstructor[], + handler: ErrorHandler + ): void { + this.errorHandlerRegistry.register(errorType, handler); + } + public abstract resolve( event: unknown, context: Context, diff --git a/packages/event-handler/src/rest/ErrorHandlerRegistry.ts b/packages/event-handler/src/rest/ErrorHandlerRegistry.ts new file mode 100644 index 0000000000..2a68b72d53 --- /dev/null +++ b/packages/event-handler/src/rest/ErrorHandlerRegistry.ts @@ -0,0 +1,74 @@ +import type { GenericLogger } from '@aws-lambda-powertools/commons/types'; +import type { + ErrorConstructor, + ErrorHandler, + ErrorHandlerRegistryOptions, +} from '../types/rest.js'; + +export class ErrorHandlerRegistry { + readonly #handlers: Map = new Map(); + + readonly #logger: Pick; + + public constructor(options: ErrorHandlerRegistryOptions) { + this.#logger = options.logger; + } + + /** + * Registers an error handler for one or more error types. + * + * The handler will be called when an error of the specified type(s) is thrown. + * If multiple error types are provided, the same handler will be registered + * for all of them. + * + * @param errorType - The error constructor(s) to register the handler for + * @param handler - The error handler function to call when the error occurs + */ + public register( + errorType: ErrorConstructor | ErrorConstructor[], + handler: ErrorHandler + ): void { + const errorTypes = Array.isArray(errorType) ? errorType : [errorType]; + + for (const type of errorTypes) { + if (this.#handlers.has(type)) { + this.#logger.warn( + `Handler for ${type.name} already exists. The previous handler will be replaced.` + ); + } + this.#handlers.set(type, handler as ErrorHandler); + } + } + + /** + * Resolves an error handler for the given error instance. + * + * The resolution process follows this order: + * 1. Exact constructor match + * 2. instanceof checks for inheritance + * 3. Name-based matching (fallback for bundling issues) + * + * @param error - The error instance to find a handler for + * @returns The error handler function or null if no match found + */ + public resolve(error: Error): ErrorHandler | null { + const exactHandler = this.#handlers.get( + error.constructor as ErrorConstructor + ); + if (exactHandler != null) return exactHandler; + + for (const [errorType, handler] of this.#handlers) { + if (error instanceof errorType) { + return handler; + } + } + + for (const [errorType, handler] of this.#handlers) { + if (error.name === errorType.name) { + return handler; + } + } + + return null; + } +} diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index 702862dd62..ffe71f3c89 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -77,6 +77,15 @@ type RouteRegistryOptions = { logger: Pick; }; +type ErrorHandlerRegistryOptions = { + /** + * A logger instance to be used for logging debug, warning, and error messages. + * + * When no logger is provided, we'll only log warnings and errors using the global `console` object. + */ + logger: Pick; +}; + type ValidationResult = { isValid: boolean; issues: string[]; @@ -87,6 +96,7 @@ export type { DynamicRoute, ErrorResponse, ErrorConstructor, + ErrorHandlerRegistryOptions, ErrorHandler, HttpStatusCode, HttpMethod, diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index 582139a8b5..ed63c0249b 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -2,7 +2,8 @@ import context from '@aws-lambda-powertools/testing-utils/context'; import type { Context } from 'aws-lambda'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BaseRouter } from '../../../src/rest/BaseRouter.js'; -import { HttpVerbs } from '../../../src/rest/constants.js'; +import { HttpErrorCodes, HttpVerbs } from '../../../src/rest/constants.js'; +import { BadRequestError } from '../../../src/rest/errors.js'; import type { HttpMethod, Path, @@ -213,4 +214,28 @@ describe('Class: BaseRouter', () => { expect(actual).toEqual(expected); }); }); + + it('handles errors through registered error handlers', async () => { + // Prepare + class TestRouterWithErrorAccess extends TestResolver { + get testErrorHandlerRegistry() { + return this.errorHandlerRegistry; + } + } + + const app = new TestRouterWithErrorAccess(); + const errorHandler = (error: BadRequestError) => ({ + statusCode: HttpErrorCodes.BAD_REQUEST, + error: error.name, + message: `Handled: ${error.message}`, + }); + + app.errorHandler(BadRequestError, errorHandler); + + // Act & Assess + const registeredHandler = app.testErrorHandlerRegistry.resolve( + new BadRequestError('test') + ); + expect(registeredHandler).toBe(errorHandler); + }); }); diff --git a/packages/event-handler/tests/unit/rest/ErrorHandlerRegistry.test.ts b/packages/event-handler/tests/unit/rest/ErrorHandlerRegistry.test.ts new file mode 100644 index 0000000000..ba233886d3 --- /dev/null +++ b/packages/event-handler/tests/unit/rest/ErrorHandlerRegistry.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import { HttpErrorCodes } from '../../../src/rest/constants.js'; +import { ErrorHandlerRegistry } from '../../../src/rest/ErrorHandlerRegistry.js'; +import type { HttpStatusCode } from '../../../src/types/rest.js'; + +const createErrorHandler = + (statusCode: HttpStatusCode, message?: string) => (error: Error) => ({ + statusCode, + error: error.name, + message: message ?? error.message, + }); + +class CustomError extends Error { + constructor(message: string) { + super(message); + this.name = 'CustomError'; + } +} + +class AnotherError extends Error { + constructor(message: string) { + super(message); + this.name = 'AnotherError'; + } +} + +class InheritedError extends CustomError { + constructor(message: string) { + super(message); + this.name = 'InheritedError'; + } +} + +describe('Class: ErrorHandlerRegistry', () => { + it('logs a warning when registering a duplicate error handler', () => { + // Prepare + const registry = new ErrorHandlerRegistry({ logger: console }); + const handler1 = createErrorHandler(HttpErrorCodes.BAD_REQUEST, 'first'); + const handler2 = createErrorHandler(HttpErrorCodes.NOT_FOUND, 'second'); + + // Act + registry.register(CustomError, handler1); + registry.register(CustomError, handler2); + + // Assess + expect(console.warn).toHaveBeenCalledWith( + 'Handler for CustomError already exists. The previous handler will be replaced.' + ); + + const result = registry.resolve(new CustomError('test')); + expect(result).toBe(handler2); + }); + + it('registers handlers for multiple error types', () => { + // Prepare + const registry = new ErrorHandlerRegistry({ logger: console }); + const handler = createErrorHandler(HttpErrorCodes.BAD_REQUEST); + + // Act + registry.register([CustomError, AnotherError], handler); + + // Assess + expect(registry.resolve(new CustomError('test'))).toBe(handler); + expect(registry.resolve(new AnotherError('test'))).toBe(handler); + }); + + it('resolves handlers using exact constructor match', () => { + // Prepare + const registry = new ErrorHandlerRegistry({ logger: console }); + const customHandler = createErrorHandler(HttpErrorCodes.BAD_REQUEST); + const anotherHandler = createErrorHandler( + HttpErrorCodes.INTERNAL_SERVER_ERROR + ); + + // Act + registry.register(CustomError, customHandler); + registry.register(AnotherError, anotherHandler); + + // Assess + expect(registry.resolve(new CustomError('test'))).toBe(customHandler); + expect(registry.resolve(new AnotherError('test'))).toBe(anotherHandler); + }); + + it('resolves handlers using instanceof for inheritance', () => { + // Prepare + const registry = new ErrorHandlerRegistry({ logger: console }); + const baseHandler = createErrorHandler(HttpErrorCodes.BAD_REQUEST); + + // Act + registry.register(CustomError, baseHandler); + + // Assess + const inheritedError = new InheritedError('test'); + expect(registry.resolve(inheritedError)).toBe(baseHandler); + }); + + it('resolves handlers using name-based matching', () => { + // Prepare + const registry = new ErrorHandlerRegistry({ logger: console }); + const handler = createErrorHandler(HttpErrorCodes.BAD_REQUEST); + + // Act + registry.register(CustomError, handler); + + const errorWithSameName = new Error('test'); + errorWithSameName.name = 'CustomError'; + + // Assess + expect(registry.resolve(errorWithSameName)).toBe(handler); + }); + + it('returns null when no handler is found', () => { + // Prepare + const registry = new ErrorHandlerRegistry({ logger: console }); + const handler = createErrorHandler(HttpErrorCodes.BAD_REQUEST); + + // Act + registry.register(CustomError, handler); + + // Assess + expect(registry.resolve(new AnotherError('test'))).toBeNull(); + expect(registry.resolve(new Error('test'))).toBeNull(); + }); + + it('prioritizes exact constructor match over instanceof', () => { + // Prepare + const registry = new ErrorHandlerRegistry({ logger: console }); + const baseHandler = createErrorHandler(HttpErrorCodes.BAD_REQUEST); + const specificHandler = createErrorHandler( + HttpErrorCodes.INTERNAL_SERVER_ERROR + ); + + // Act + registry.register(CustomError, baseHandler); + registry.register(InheritedError, specificHandler); + + // Assess + expect(registry.resolve(new InheritedError('test'))).toBe(specificHandler); + }); + + it('prioritizes instanceof match over name-based matching', () => { + // Prepare + const registry = new ErrorHandlerRegistry({ logger: console }); + const baseHandler = createErrorHandler(HttpErrorCodes.BAD_REQUEST); + const nameHandler = createErrorHandler( + HttpErrorCodes.INTERNAL_SERVER_ERROR + ); + + // Create a class with different name but register with name matching + class DifferentNameError extends Error { + constructor(message: string) { + super(message); + this.name = 'CustomError'; // Same name as CustomError + } + } + + // Act + registry.register(CustomError, baseHandler); + registry.register(DifferentNameError, nameHandler); + + const error = new DifferentNameError('test'); + + // Assess + expect(registry.resolve(error)).toBe(nameHandler); + }); +});