Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions packages/event-handler/src/rest/BaseRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,24 @@ import {
import type { Context } from 'aws-lambda';
import type { ResolveOptions } from '../types/index.js';
import type {
ErrorConstructor,
ErrorHandler,
HttpMethod,
Path,
RouteHandler,
RouteOptions,
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<string, unknown>;

protected routeRegistry: RouteHandlerRegistry;
protected readonly routeRegistry: RouteHandlerRegistry;
protected readonly errorHandlerRegistry: ErrorHandlerRegistry;

/**
* A logger instance to be used for logging debug, warning, and error messages.
Expand All @@ -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',
Expand All @@ -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<T extends Error>(
errorType: ErrorConstructor<T> | ErrorConstructor<T>[],
handler: ErrorHandler<T>
): void {
this.errorHandlerRegistry.register(errorType, handler);
}

public abstract resolve(
event: unknown,
context: Context,
Expand Down
74 changes: 74 additions & 0 deletions packages/event-handler/src/rest/ErrorHandlerRegistry.ts
Original file line number Diff line number Diff line change
@@ -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<ErrorConstructor, ErrorHandler> = new Map();

readonly #logger: Pick<GenericLogger, 'debug' | 'warn' | 'error'>;

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<T extends Error>(
errorType: ErrorConstructor<T> | ErrorConstructor<T>[],
handler: ErrorHandler<T>
): 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;
}
}
10 changes: 10 additions & 0 deletions packages/event-handler/src/types/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ type RouteRegistryOptions = {
logger: Pick<GenericLogger, 'debug' | 'warn' | 'error'>;
};

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<GenericLogger, 'debug' | 'warn' | 'error'>;
};

type ValidationResult = {
isValid: boolean;
issues: string[];
Expand All @@ -87,6 +96,7 @@ export type {
DynamicRoute,
ErrorResponse,
ErrorConstructor,
ErrorHandlerRegistryOptions,
ErrorHandler,
HttpStatusCode,
HttpMethod,
Expand Down
27 changes: 26 additions & 1 deletion packages/event-handler/tests/unit/rest/BaseRouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
});
166 changes: 166 additions & 0 deletions packages/event-handler/tests/unit/rest/ErrorHandlerRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});