Skip to content

Commit 180bc8e

Browse files
committed
feat(event-handler): add event handler registry
1 parent eba63de commit 180bc8e

File tree

5 files changed

+326
-2
lines changed

5 files changed

+326
-2
lines changed

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

Lines changed: 15 additions & 1 deletion
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

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

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: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { HttpErrorCodes } from '../../../src/rest/constants.js';
3+
import { ErrorHandlerRegistry } from '../../../src/rest/ErrorHandlerRegistry.js';
4+
5+
class CustomError extends Error {
6+
constructor(message: string) {
7+
super(message);
8+
this.name = 'CustomError';
9+
}
10+
}
11+
12+
class AnotherError extends Error {
13+
constructor(message: string) {
14+
super(message);
15+
this.name = 'AnotherError';
16+
}
17+
}
18+
19+
class InheritedError extends CustomError {
20+
constructor(message: string) {
21+
super(message);
22+
this.name = 'InheritedError';
23+
}
24+
}
25+
26+
describe('Class: ErrorHandlerRegistry', () => {
27+
it('logs a warning when registering a duplicate error handler', () => {
28+
// Prepare
29+
const registry = new ErrorHandlerRegistry({ logger: console });
30+
const handler1 = () => ({
31+
statusCode: HttpErrorCodes.BAD_REQUEST,
32+
error: 'CustomError',
33+
message: 'first',
34+
});
35+
const handler2 = () => ({
36+
statusCode: HttpErrorCodes.BAD_REQUEST,
37+
error: 'CustomError',
38+
message: 'second',
39+
});
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 = () => ({
58+
statusCode: HttpErrorCodes.BAD_REQUEST,
59+
error: 'Error',
60+
message: 'error',
61+
});
62+
63+
// Act
64+
registry.register([CustomError, AnotherError], handler);
65+
66+
// Assess
67+
expect(registry.resolve(new CustomError('test'))).toBe(handler);
68+
expect(registry.resolve(new AnotherError('test'))).toBe(handler);
69+
});
70+
71+
it('resolves handlers using exact constructor match', () => {
72+
// Prepare
73+
const registry = new ErrorHandlerRegistry({ logger: console });
74+
const customHandler = () => ({
75+
statusCode: HttpErrorCodes.BAD_REQUEST,
76+
error: 'CustomError',
77+
message: 'custom',
78+
});
79+
const anotherHandler = () => ({
80+
statusCode: HttpErrorCodes.INTERNAL_SERVER_ERROR,
81+
error: 'AnotherError',
82+
message: 'another',
83+
});
84+
85+
// Act
86+
registry.register(CustomError, customHandler);
87+
registry.register(AnotherError, anotherHandler);
88+
89+
// Assess
90+
expect(registry.resolve(new CustomError('test'))).toBe(customHandler);
91+
expect(registry.resolve(new AnotherError('test'))).toBe(anotherHandler);
92+
});
93+
94+
it('resolves handlers using instanceof for inheritance', () => {
95+
// Prepare
96+
const registry = new ErrorHandlerRegistry({ logger: console });
97+
const baseHandler = () => ({
98+
statusCode: HttpErrorCodes.BAD_REQUEST,
99+
error: 'CustomError',
100+
message: 'base',
101+
});
102+
103+
// Act
104+
registry.register(CustomError, baseHandler);
105+
106+
// Assess
107+
const inheritedError = new InheritedError('test');
108+
expect(registry.resolve(inheritedError)).toBe(baseHandler);
109+
});
110+
111+
it('resolves handlers using name-based matching', () => {
112+
// Prepare
113+
const registry = new ErrorHandlerRegistry({ logger: console });
114+
const handler = () => ({
115+
statusCode: HttpErrorCodes.BAD_REQUEST,
116+
error: 'CustomError',
117+
message: 'error',
118+
});
119+
120+
// Act
121+
registry.register(CustomError, handler);
122+
123+
const errorWithSameName = new Error('test');
124+
errorWithSameName.name = 'CustomError';
125+
126+
// Assess
127+
expect(registry.resolve(errorWithSameName)).toBe(handler);
128+
});
129+
130+
it('returns null when no handler is found', () => {
131+
// Prepare
132+
const registry = new ErrorHandlerRegistry({ logger: console });
133+
const handler = () => ({
134+
statusCode: HttpErrorCodes.BAD_REQUEST,
135+
error: 'CustomError',
136+
message: 'error',
137+
});
138+
139+
// Act
140+
registry.register(CustomError, handler);
141+
142+
// Assess
143+
expect(registry.resolve(new AnotherError('test'))).toBeNull();
144+
expect(registry.resolve(new Error('test'))).toBeNull();
145+
});
146+
147+
it('prioritizes exact constructor match over instanceof', () => {
148+
// Prepare
149+
const registry = new ErrorHandlerRegistry({ logger: console });
150+
const baseHandler = () => ({
151+
statusCode: HttpErrorCodes.BAD_REQUEST,
152+
error: 'CustomError',
153+
message: 'base',
154+
});
155+
const specificHandler = () => ({
156+
statusCode: HttpErrorCodes.INTERNAL_SERVER_ERROR,
157+
error: 'InheritedError',
158+
message: 'specific',
159+
});
160+
161+
// Act
162+
registry.register(CustomError, baseHandler);
163+
registry.register(InheritedError, specificHandler);
164+
165+
// Assess
166+
expect(registry.resolve(new InheritedError('test'))).toBe(specificHandler);
167+
});
168+
169+
it('prioritizes instanceof match over name-based matching', () => {
170+
// Prepare
171+
const registry = new ErrorHandlerRegistry({ logger: console });
172+
const baseHandler = () => ({
173+
statusCode: HttpErrorCodes.BAD_REQUEST,
174+
error: 'CustomError',
175+
message: 'base',
176+
});
177+
const nameHandler = () => ({
178+
statusCode: HttpErrorCodes.INTERNAL_SERVER_ERROR,
179+
error: 'DifferentNameError',
180+
message: 'name',
181+
});
182+
183+
// Create a class with different name but register with name matching
184+
class DifferentNameError extends Error {
185+
constructor(message: string) {
186+
super(message);
187+
this.name = 'CustomError'; // Same name as CustomError
188+
}
189+
}
190+
191+
// Act
192+
registry.register(CustomError, baseHandler);
193+
registry.register(DifferentNameError, nameHandler);
194+
195+
const error = new DifferentNameError('test');
196+
197+
// Assess
198+
expect(registry.resolve(error)).toBe(nameHandler);
199+
});
200+
});

0 commit comments

Comments
 (0)