Skip to content

Commit aaac429

Browse files
authored
feat(event-handler): add event handler registry (#4307)
1 parent eba63de commit aaac429

File tree

5 files changed

+292
-3
lines changed

5 files changed

+292
-3
lines changed

packages/event-handler/src/rest/BaseRouter.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,24 @@ import {
66
import type { Context } from 'aws-lambda';
77
import type { ResolveOptions } from '../types/index.js';
88
import type {
9+
ErrorConstructor,
10+
ErrorHandler,
911
HttpMethod,
1012
Path,
1113
RouteHandler,
1214
RouteOptions,
1315
RouterOptions,
1416
} from '../types/rest.js';
1517
import { HttpVerbs } from './constants.js';
18+
import { ErrorHandlerRegistry } from './ErrorHandlerRegistry.js';
1619
import { Route } from './Route.js';
1720
import { RouteHandlerRegistry } from './RouteHandlerRegistry.js';
1821

1922
abstract class BaseRouter {
2023
protected context: Record<string, unknown>;
2124

22-
protected routeRegistry: RouteHandlerRegistry;
25+
protected readonly routeRegistry: RouteHandlerRegistry;
26+
protected readonly errorHandlerRegistry: ErrorHandlerRegistry;
2327

2428
/**
2529
* A logger instance to be used for logging debug, warning, and error messages.
@@ -32,7 +36,7 @@ abstract class BaseRouter {
3236
*/
3337
protected readonly isDev: boolean = false;
3438

35-
public constructor(options?: RouterOptions) {
39+
protected constructor(options?: RouterOptions) {
3640
this.context = {};
3741
const alcLogLevel = getStringFromEnv({
3842
key: 'AWS_LAMBDA_LOG_LEVEL',
@@ -44,9 +48,19 @@ abstract class BaseRouter {
4448
warn: console.warn,
4549
};
4650
this.routeRegistry = new RouteHandlerRegistry({ logger: this.logger });
51+
this.errorHandlerRegistry = new ErrorHandlerRegistry({
52+
logger: this.logger,
53+
});
4754
this.isDev = isDevMode();
4855
}
4956

57+
public errorHandler<T extends Error>(
58+
errorType: ErrorConstructor<T> | ErrorConstructor<T>[],
59+
handler: ErrorHandler<T>
60+
): void {
61+
this.errorHandlerRegistry.register(errorType, handler);
62+
}
63+
5064
public abstract resolve(
5165
event: unknown,
5266
context: Context,
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { GenericLogger } from '@aws-lambda-powertools/commons/types';
2+
import type {
3+
ErrorConstructor,
4+
ErrorHandler,
5+
ErrorHandlerRegistryOptions,
6+
} from '../types/rest.js';
7+
8+
export class ErrorHandlerRegistry {
9+
readonly #handlers: Map<ErrorConstructor, ErrorHandler> = new Map();
10+
11+
readonly #logger: Pick<GenericLogger, 'debug' | 'warn' | 'error'>;
12+
13+
public constructor(options: ErrorHandlerRegistryOptions) {
14+
this.#logger = options.logger;
15+
}
16+
17+
/**
18+
* Registers an error handler for one or more error types.
19+
*
20+
* The handler will be called when an error of the specified type(s) is thrown.
21+
* If multiple error types are provided, the same handler will be registered
22+
* for all of them.
23+
*
24+
* @param errorType - The error constructor(s) to register the handler for
25+
* @param handler - The error handler function to call when the error occurs
26+
*/
27+
public register<T extends Error>(
28+
errorType: ErrorConstructor<T> | ErrorConstructor<T>[],
29+
handler: ErrorHandler<T>
30+
): void {
31+
const errorTypes = Array.isArray(errorType) ? errorType : [errorType];
32+
33+
for (const type of errorTypes) {
34+
if (this.#handlers.has(type)) {
35+
this.#logger.warn(
36+
`Handler for ${type.name} already exists. The previous handler will be replaced.`
37+
);
38+
}
39+
this.#handlers.set(type, handler as ErrorHandler);
40+
}
41+
}
42+
43+
/**
44+
* Resolves an error handler for the given error instance.
45+
*
46+
* The resolution process follows this order:
47+
* 1. Exact constructor match
48+
* 2. instanceof checks for inheritance
49+
* 3. Name-based matching (fallback for bundling issues)
50+
*
51+
* @param error - The error instance to find a handler for
52+
* @returns The error handler function or null if no match found
53+
*/
54+
public resolve(error: Error): ErrorHandler | null {
55+
const exactHandler = this.#handlers.get(
56+
error.constructor as ErrorConstructor
57+
);
58+
if (exactHandler != null) return exactHandler;
59+
60+
for (const [errorType, handler] of this.#handlers) {
61+
if (error instanceof errorType) {
62+
return handler;
63+
}
64+
}
65+
66+
for (const [errorType, handler] of this.#handlers) {
67+
if (error.name === errorType.name) {
68+
return handler;
69+
}
70+
}
71+
72+
return null;
73+
}
74+
}

packages/event-handler/src/types/rest.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,15 @@ type RouteRegistryOptions = {
7777
logger: Pick<GenericLogger, 'debug' | 'warn' | 'error'>;
7878
};
7979

80+
type ErrorHandlerRegistryOptions = {
81+
/**
82+
* A logger instance to be used for logging debug, warning, and error messages.
83+
*
84+
* When no logger is provided, we'll only log warnings and errors using the global `console` object.
85+
*/
86+
logger: Pick<GenericLogger, 'debug' | 'warn' | 'error'>;
87+
};
88+
8089
type ValidationResult = {
8190
isValid: boolean;
8291
issues: string[];
@@ -87,6 +96,7 @@ export type {
8796
DynamicRoute,
8897
ErrorResponse,
8998
ErrorConstructor,
99+
ErrorHandlerRegistryOptions,
90100
ErrorHandler,
91101
HttpStatusCode,
92102
HttpMethod,

packages/event-handler/tests/unit/rest/BaseRouter.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import context from '@aws-lambda-powertools/testing-utils/context';
22
import type { Context } from 'aws-lambda';
33
import { beforeEach, describe, expect, it, vi } from 'vitest';
44
import { BaseRouter } from '../../../src/rest/BaseRouter.js';
5-
import { HttpVerbs } from '../../../src/rest/constants.js';
5+
import { HttpErrorCodes, HttpVerbs } from '../../../src/rest/constants.js';
6+
import { BadRequestError } from '../../../src/rest/errors.js';
67
import type {
78
HttpMethod,
89
Path,
@@ -213,4 +214,28 @@ describe('Class: BaseRouter', () => {
213214
expect(actual).toEqual(expected);
214215
});
215216
});
217+
218+
it('handles errors through registered error handlers', async () => {
219+
// Prepare
220+
class TestRouterWithErrorAccess extends TestResolver {
221+
get testErrorHandlerRegistry() {
222+
return this.errorHandlerRegistry;
223+
}
224+
}
225+
226+
const app = new TestRouterWithErrorAccess();
227+
const errorHandler = (error: BadRequestError) => ({
228+
statusCode: HttpErrorCodes.BAD_REQUEST,
229+
error: error.name,
230+
message: `Handled: ${error.message}`,
231+
});
232+
233+
app.errorHandler(BadRequestError, errorHandler);
234+
235+
// Act & Assess
236+
const registeredHandler = app.testErrorHandlerRegistry.resolve(
237+
new BadRequestError('test')
238+
);
239+
expect(registeredHandler).toBe(errorHandler);
240+
});
216241
});
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { HttpErrorCodes } from '../../../src/rest/constants.js';
3+
import { ErrorHandlerRegistry } from '../../../src/rest/ErrorHandlerRegistry.js';
4+
import type { HttpStatusCode } from '../../../src/types/rest.js';
5+
6+
const createErrorHandler =
7+
(statusCode: HttpStatusCode, message?: string) => (error: Error) => ({
8+
statusCode,
9+
error: error.name,
10+
message: message ?? error.message,
11+
});
12+
13+
class CustomError extends Error {
14+
constructor(message: string) {
15+
super(message);
16+
this.name = 'CustomError';
17+
}
18+
}
19+
20+
class AnotherError extends Error {
21+
constructor(message: string) {
22+
super(message);
23+
this.name = 'AnotherError';
24+
}
25+
}
26+
27+
class InheritedError extends CustomError {
28+
constructor(message: string) {
29+
super(message);
30+
this.name = 'InheritedError';
31+
}
32+
}
33+
34+
describe('Class: ErrorHandlerRegistry', () => {
35+
it('logs a warning when registering a duplicate error handler', () => {
36+
// Prepare
37+
const registry = new ErrorHandlerRegistry({ logger: console });
38+
const handler1 = createErrorHandler(HttpErrorCodes.BAD_REQUEST, 'first');
39+
const handler2 = createErrorHandler(HttpErrorCodes.NOT_FOUND, 'second');
40+
41+
// Act
42+
registry.register(CustomError, handler1);
43+
registry.register(CustomError, handler2);
44+
45+
// Assess
46+
expect(console.warn).toHaveBeenCalledWith(
47+
'Handler for CustomError already exists. The previous handler will be replaced.'
48+
);
49+
50+
const result = registry.resolve(new CustomError('test'));
51+
expect(result).toBe(handler2);
52+
});
53+
54+
it('registers handlers for multiple error types', () => {
55+
// Prepare
56+
const registry = new ErrorHandlerRegistry({ logger: console });
57+
const handler = createErrorHandler(HttpErrorCodes.BAD_REQUEST);
58+
59+
// Act
60+
registry.register([CustomError, AnotherError], handler);
61+
62+
// Assess
63+
expect(registry.resolve(new CustomError('test'))).toBe(handler);
64+
expect(registry.resolve(new AnotherError('test'))).toBe(handler);
65+
});
66+
67+
it('resolves handlers using exact constructor match', () => {
68+
// Prepare
69+
const registry = new ErrorHandlerRegistry({ logger: console });
70+
const customHandler = createErrorHandler(HttpErrorCodes.BAD_REQUEST);
71+
const anotherHandler = createErrorHandler(
72+
HttpErrorCodes.INTERNAL_SERVER_ERROR
73+
);
74+
75+
// Act
76+
registry.register(CustomError, customHandler);
77+
registry.register(AnotherError, anotherHandler);
78+
79+
// Assess
80+
expect(registry.resolve(new CustomError('test'))).toBe(customHandler);
81+
expect(registry.resolve(new AnotherError('test'))).toBe(anotherHandler);
82+
});
83+
84+
it('resolves handlers using instanceof for inheritance', () => {
85+
// Prepare
86+
const registry = new ErrorHandlerRegistry({ logger: console });
87+
const baseHandler = createErrorHandler(HttpErrorCodes.BAD_REQUEST);
88+
89+
// Act
90+
registry.register(CustomError, baseHandler);
91+
92+
// Assess
93+
const inheritedError = new InheritedError('test');
94+
expect(registry.resolve(inheritedError)).toBe(baseHandler);
95+
});
96+
97+
it('resolves handlers using name-based matching', () => {
98+
// Prepare
99+
const registry = new ErrorHandlerRegistry({ logger: console });
100+
const handler = createErrorHandler(HttpErrorCodes.BAD_REQUEST);
101+
102+
// Act
103+
registry.register(CustomError, handler);
104+
105+
const errorWithSameName = new Error('test');
106+
errorWithSameName.name = 'CustomError';
107+
108+
// Assess
109+
expect(registry.resolve(errorWithSameName)).toBe(handler);
110+
});
111+
112+
it('returns null when no handler is found', () => {
113+
// Prepare
114+
const registry = new ErrorHandlerRegistry({ logger: console });
115+
const handler = createErrorHandler(HttpErrorCodes.BAD_REQUEST);
116+
117+
// Act
118+
registry.register(CustomError, handler);
119+
120+
// Assess
121+
expect(registry.resolve(new AnotherError('test'))).toBeNull();
122+
expect(registry.resolve(new Error('test'))).toBeNull();
123+
});
124+
125+
it('prioritizes exact constructor match over instanceof', () => {
126+
// Prepare
127+
const registry = new ErrorHandlerRegistry({ logger: console });
128+
const baseHandler = createErrorHandler(HttpErrorCodes.BAD_REQUEST);
129+
const specificHandler = createErrorHandler(
130+
HttpErrorCodes.INTERNAL_SERVER_ERROR
131+
);
132+
133+
// Act
134+
registry.register(CustomError, baseHandler);
135+
registry.register(InheritedError, specificHandler);
136+
137+
// Assess
138+
expect(registry.resolve(new InheritedError('test'))).toBe(specificHandler);
139+
});
140+
141+
it('prioritizes instanceof match over name-based matching', () => {
142+
// Prepare
143+
const registry = new ErrorHandlerRegistry({ logger: console });
144+
const baseHandler = createErrorHandler(HttpErrorCodes.BAD_REQUEST);
145+
const nameHandler = createErrorHandler(
146+
HttpErrorCodes.INTERNAL_SERVER_ERROR
147+
);
148+
149+
// Create a class with different name but register with name matching
150+
class DifferentNameError extends Error {
151+
constructor(message: string) {
152+
super(message);
153+
this.name = 'CustomError'; // Same name as CustomError
154+
}
155+
}
156+
157+
// Act
158+
registry.register(CustomError, baseHandler);
159+
registry.register(DifferentNameError, nameHandler);
160+
161+
const error = new DifferentNameError('test');
162+
163+
// Assess
164+
expect(registry.resolve(error)).toBe(nameHandler);
165+
});
166+
});

0 commit comments

Comments
 (0)