From f388c9ffcccde85fdca70115af20ac3796c47834 Mon Sep 17 00:00:00 2001 From: svozza Date: Wed, 13 Aug 2025 12:15:37 +0100 Subject: [PATCH 1/2] feat(event-handler): add decorator functioanlity for error handlers --- packages/event-handler/src/rest/BaseRouter.ts | 62 +++++- .../tests/unit/rest/BaseRouter.test.ts | 186 +++++++++++++++++- 2 files changed, 237 insertions(+), 11 deletions(-) diff --git a/packages/event-handler/src/rest/BaseRouter.ts b/packages/event-handler/src/rest/BaseRouter.ts index 85b6aa44ec..76a1dc45f6 100644 --- a/packages/event-handler/src/rest/BaseRouter.ts +++ b/packages/event-handler/src/rest/BaseRouter.ts @@ -68,8 +68,23 @@ abstract class BaseRouter { public errorHandler( errorType: ErrorConstructor | ErrorConstructor[], handler: ErrorHandler - ): void { - this.errorHandlerRegistry.register(errorType, handler); + ): void; + public errorHandler( + errorType: ErrorConstructor | ErrorConstructor[] + ): MethodDecorator; + public errorHandler( + errorType: ErrorConstructor | ErrorConstructor[], + handler?: ErrorHandler + ): MethodDecorator | undefined { + if (handler && typeof handler === 'function') { + this.errorHandlerRegistry.register(errorType, handler); + return; + } + + return (_target, _propertyKey, descriptor: PropertyDescriptor) => { + this.errorHandlerRegistry.register(errorType, descriptor?.value); + return descriptor; + }; } /** @@ -77,8 +92,20 @@ abstract class BaseRouter { * * @param handler - The error handler function for NotFoundError */ - public notFound(handler: ErrorHandler): void { - this.errorHandlerRegistry.register(NotFoundError, handler); + public notFound(handler: ErrorHandler): void; + public notFound(): MethodDecorator; + public notFound( + handler?: ErrorHandler + ): MethodDecorator | undefined { + if (handler && typeof handler === 'function') { + this.errorHandlerRegistry.register(NotFoundError, handler); + return; + } + + return (_target, _propertyKey, descriptor: PropertyDescriptor) => { + this.errorHandlerRegistry.register(NotFoundError, descriptor?.value); + return descriptor; + }; } /** @@ -86,8 +113,23 @@ abstract class BaseRouter { * * @param handler - The error handler function for MethodNotAllowedError */ - public methodNotAllowed(handler: ErrorHandler): void { - this.errorHandlerRegistry.register(MethodNotAllowedError, handler); + public methodNotAllowed(handler: ErrorHandler): void; + public methodNotAllowed(): MethodDecorator; + public methodNotAllowed( + handler?: ErrorHandler + ): MethodDecorator | undefined { + if (handler && typeof handler === 'function') { + this.errorHandlerRegistry.register(MethodNotAllowedError, handler); + return; + } + + return (_target, _propertyKey, descriptor: PropertyDescriptor) => { + this.errorHandlerRegistry.register( + MethodNotAllowedError, + descriptor?.value + ); + return descriptor; + }; } public abstract resolve( @@ -110,13 +152,17 @@ abstract class BaseRouter { * back to a default handler. * * @param error - The error to handle + * @param options - Optional resolve options for scope binding * @returns A Response object with appropriate status code and error details */ - protected async handleError(error: Error): Promise { + protected async handleError( + error: Error, + options?: ResolveOptions + ): Promise { const handler = this.errorHandlerRegistry.resolve(error); if (handler !== null) { try { - const body = await handler(error); + const body = await handler.apply(options?.scope ?? this, [error]); return new Response(JSON.stringify(body), { status: body.statusCode, headers: { 'Content-Type': 'application/json' }, diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index 4326b7da6f..3c1b16fcec 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -40,16 +40,20 @@ describe('Class: BaseRouter', () => { } } - public async resolve(event: unknown, context: Context): Promise { + public async resolve( + event: unknown, + context: Context, + options?: any + ): Promise { this.#isEvent(event); const { method, path } = event; const route = this.routeRegistry.resolve(method, path); try { if (route == null) throw new NotFoundError(`Route ${method} ${path} not found`); - return route.handler(event, context); + return await route.handler(event, context); } catch (error) { - return await this.handleError(error as Error); + return await this.handleError(error as Error, options); } } } @@ -589,5 +593,181 @@ describe('Class: BaseRouter', () => { // Assess expect(result.headers.get('Content-Type')).toBe('application/json'); }); + + describe('decorators', () => { + it('works with errorHandler decorator', async () => { + // Prepare + const app = new TestResolver(); + + class Lambda { + @app.errorHandler(BadRequestError) + public async handleBadRequest(error: BadRequestError) { + return { + statusCode: HttpErrorCodes.BAD_REQUEST, + error: 'Bad Request', + message: `Decorated: ${error.message}`, + }; + } + + @app.get('/test') + public async getTest() { + throw new BadRequestError('test error'); + } + + public async handler(event: unknown, context: Context) { + return app.resolve(event, context); + } + } + + const lambda = new Lambda(); + + // Act + const result = (await lambda.handler( + { path: '/test', method: 'GET' }, + context + )) as Response; + + // Assess + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(HttpErrorCodes.BAD_REQUEST); + expect(await result.text()).toBe( + JSON.stringify({ + statusCode: HttpErrorCodes.BAD_REQUEST, + error: 'Bad Request', + message: 'Decorated: test error', + }) + ); + }); + + it('works with notFound decorator', async () => { + // Prepare + const app = new TestResolver(); + + class Lambda { + @app.notFound() + public async handleNotFound(error: NotFoundError) { + return { + statusCode: HttpErrorCodes.NOT_FOUND, + error: 'Not Found', + message: `Decorated: ${error.message}`, + }; + } + + public async handler(event: unknown, context: Context) { + return app.resolve(event, context); + } + } + + const lambda = new Lambda(); + + // Act + const result = (await lambda.handler( + { path: '/nonexistent', method: 'GET' }, + context + )) as Response; + + // Assess + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(HttpErrorCodes.NOT_FOUND); + expect(await result.text()).toBe( + JSON.stringify({ + statusCode: HttpErrorCodes.NOT_FOUND, + error: 'Not Found', + message: 'Decorated: Route GET /nonexistent not found', + }) + ); + }); + + it('works with methodNotAllowed decorator', async () => { + // Prepare + const app = new TestResolver(); + + class Lambda { + @app.methodNotAllowed() + public async handleMethodNotAllowed(error: MethodNotAllowedError) { + return { + statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED, + error: 'Method Not Allowed', + message: `Decorated: ${error.message}`, + }; + } + + @app.get('/test') + public async getTest() { + throw new MethodNotAllowedError('POST not allowed'); + } + + public async handler(event: unknown, context: Context) { + return app.resolve(event, context); + } + } + + const lambda = new Lambda(); + + // Act + const result = (await lambda.handler( + { path: '/test', method: 'GET' }, + context + )) as Response; + + // Assess + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(HttpErrorCodes.METHOD_NOT_ALLOWED); + expect(await result.text()).toBe( + JSON.stringify({ + statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED, + error: 'Method Not Allowed', + message: 'Decorated: POST not allowed', + }) + ); + }); + + it('preserves scope when using error handler decorators', async () => { + // Prepare + const app = new TestResolver(); + + class Lambda { + public scope = 'scoped'; + + @app.errorHandler(BadRequestError) + public async handleBadRequest(error: BadRequestError) { + return { + statusCode: HttpErrorCodes.BAD_REQUEST, + error: 'Bad Request', + message: `${this.scope}: ${error.message}`, + }; + } + + @app.get('/test') + public async getTest() { + throw new BadRequestError('test error'); + } + + public async handler(event: unknown, context: Context) { + return app.resolve(event, context, { scope: this }); + } + } + + const lambda = new Lambda(); + const handler = lambda.handler.bind(lambda); + + // Act + const result = (await handler( + { path: '/test', method: 'GET' }, + context + )) as Response; + + // Assess + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(HttpErrorCodes.BAD_REQUEST); + expect(await result.text()).toBe( + JSON.stringify({ + statusCode: HttpErrorCodes.BAD_REQUEST, + error: 'Bad Request', + message: 'scoped: test error', + }) + ); + }); + }); }); }); From cda64c9ff5cf2234b5d35467f67a363064f14bc0 Mon Sep 17 00:00:00 2001 From: svozza Date: Wed, 13 Aug 2025 13:59:55 +0100 Subject: [PATCH 2/2] move decorator tests to own describe block --- .../tests/unit/rest/BaseRouter.test.ts | 300 +++++++++--------- 1 file changed, 150 insertions(+), 150 deletions(-) diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index 3c1b16fcec..8033b50d22 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -593,181 +593,181 @@ describe('Class: BaseRouter', () => { // Assess expect(result.headers.get('Content-Type')).toBe('application/json'); }); + }); - describe('decorators', () => { - it('works with errorHandler decorator', async () => { - // Prepare - const app = new TestResolver(); - - class Lambda { - @app.errorHandler(BadRequestError) - public async handleBadRequest(error: BadRequestError) { - return { - statusCode: HttpErrorCodes.BAD_REQUEST, - error: 'Bad Request', - message: `Decorated: ${error.message}`, - }; - } - - @app.get('/test') - public async getTest() { - throw new BadRequestError('test error'); - } - - public async handler(event: unknown, context: Context) { - return app.resolve(event, context); - } - } - - const lambda = new Lambda(); - - // Act - const result = (await lambda.handler( - { path: '/test', method: 'GET' }, - context - )) as Response; + describe('decorators error handling', () => { + it('works with errorHandler decorator', async () => { + // Prepare + const app = new TestResolver(); - // Assess - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(HttpErrorCodes.BAD_REQUEST); - expect(await result.text()).toBe( - JSON.stringify({ + class Lambda { + @app.errorHandler(BadRequestError) + public async handleBadRequest(error: BadRequestError) { + return { statusCode: HttpErrorCodes.BAD_REQUEST, error: 'Bad Request', - message: 'Decorated: test error', - }) - ); - }); + message: `Decorated: ${error.message}`, + }; + } + + @app.get('/test') + public async getTest() { + throw new BadRequestError('test error'); + } - it('works with notFound decorator', async () => { - // Prepare - const app = new TestResolver(); - - class Lambda { - @app.notFound() - public async handleNotFound(error: NotFoundError) { - return { - statusCode: HttpErrorCodes.NOT_FOUND, - error: 'Not Found', - message: `Decorated: ${error.message}`, - }; - } - - public async handler(event: unknown, context: Context) { - return app.resolve(event, context); - } + public async handler(event: unknown, context: Context) { + return app.resolve(event, context); } + } - const lambda = new Lambda(); + const lambda = new Lambda(); - // Act - const result = (await lambda.handler( - { path: '/nonexistent', method: 'GET' }, - context - )) as Response; + // Act + const result = (await lambda.handler( + { path: '/test', method: 'GET' }, + context + )) as Response; - // Assess - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(HttpErrorCodes.NOT_FOUND); - expect(await result.text()).toBe( - JSON.stringify({ + // Assess + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(HttpErrorCodes.BAD_REQUEST); + expect(await result.text()).toBe( + JSON.stringify({ + statusCode: HttpErrorCodes.BAD_REQUEST, + error: 'Bad Request', + message: 'Decorated: test error', + }) + ); + }); + + it('works with notFound decorator', async () => { + // Prepare + const app = new TestResolver(); + + class Lambda { + @app.notFound() + public async handleNotFound(error: NotFoundError) { + return { statusCode: HttpErrorCodes.NOT_FOUND, error: 'Not Found', - message: 'Decorated: Route GET /nonexistent not found', - }) - ); - }); + message: `Decorated: ${error.message}`, + }; + } - it('works with methodNotAllowed decorator', async () => { - // Prepare - const app = new TestResolver(); - - class Lambda { - @app.methodNotAllowed() - public async handleMethodNotAllowed(error: MethodNotAllowedError) { - return { - statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED, - error: 'Method Not Allowed', - message: `Decorated: ${error.message}`, - }; - } - - @app.get('/test') - public async getTest() { - throw new MethodNotAllowedError('POST not allowed'); - } - - public async handler(event: unknown, context: Context) { - return app.resolve(event, context); - } + public async handler(event: unknown, context: Context) { + return app.resolve(event, context); } + } - const lambda = new Lambda(); + const lambda = new Lambda(); - // Act - const result = (await lambda.handler( - { path: '/test', method: 'GET' }, - context - )) as Response; + // Act + const result = (await lambda.handler( + { path: '/nonexistent', method: 'GET' }, + context + )) as Response; - // Assess - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(HttpErrorCodes.METHOD_NOT_ALLOWED); - expect(await result.text()).toBe( - JSON.stringify({ + // Assess + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(HttpErrorCodes.NOT_FOUND); + expect(await result.text()).toBe( + JSON.stringify({ + statusCode: HttpErrorCodes.NOT_FOUND, + error: 'Not Found', + message: 'Decorated: Route GET /nonexistent not found', + }) + ); + }); + + it('works with methodNotAllowed decorator', async () => { + // Prepare + const app = new TestResolver(); + + class Lambda { + @app.methodNotAllowed() + public async handleMethodNotAllowed(error: MethodNotAllowedError) { + return { statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED, error: 'Method Not Allowed', - message: 'Decorated: POST not allowed', - }) - ); - }); + message: `Decorated: ${error.message}`, + }; + } - it('preserves scope when using error handler decorators', async () => { - // Prepare - const app = new TestResolver(); - - class Lambda { - public scope = 'scoped'; - - @app.errorHandler(BadRequestError) - public async handleBadRequest(error: BadRequestError) { - return { - statusCode: HttpErrorCodes.BAD_REQUEST, - error: 'Bad Request', - message: `${this.scope}: ${error.message}`, - }; - } - - @app.get('/test') - public async getTest() { - throw new BadRequestError('test error'); - } - - public async handler(event: unknown, context: Context) { - return app.resolve(event, context, { scope: this }); - } + @app.get('/test') + public async getTest() { + throw new MethodNotAllowedError('POST not allowed'); } - const lambda = new Lambda(); - const handler = lambda.handler.bind(lambda); + public async handler(event: unknown, context: Context) { + return app.resolve(event, context); + } + } + + const lambda = new Lambda(); + + // Act + const result = (await lambda.handler( + { path: '/test', method: 'GET' }, + context + )) as Response; + + // Assess + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(HttpErrorCodes.METHOD_NOT_ALLOWED); + expect(await result.text()).toBe( + JSON.stringify({ + statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED, + error: 'Method Not Allowed', + message: 'Decorated: POST not allowed', + }) + ); + }); + + it('preserves scope when using error handler decorators', async () => { + // Prepare + const app = new TestResolver(); - // Act - const result = (await handler( - { path: '/test', method: 'GET' }, - context - )) as Response; + class Lambda { + public scope = 'scoped'; - // Assess - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(HttpErrorCodes.BAD_REQUEST); - expect(await result.text()).toBe( - JSON.stringify({ + @app.errorHandler(BadRequestError) + public async handleBadRequest(error: BadRequestError) { + return { statusCode: HttpErrorCodes.BAD_REQUEST, error: 'Bad Request', - message: 'scoped: test error', - }) - ); - }); + message: `${this.scope}: ${error.message}`, + }; + } + + @app.get('/test') + public async getTest() { + throw new BadRequestError('test error'); + } + + public async handler(event: unknown, context: Context) { + return app.resolve(event, context, { scope: this }); + } + } + + const lambda = new Lambda(); + const handler = lambda.handler.bind(lambda); + + // Act + const result = (await handler( + { path: '/test', method: 'GET' }, + context + )) as Response; + + // Assess + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(HttpErrorCodes.BAD_REQUEST); + expect(await result.text()).toBe( + JSON.stringify({ + statusCode: HttpErrorCodes.BAD_REQUEST, + error: 'Bad Request', + message: 'scoped: test error', + }) + ); }); }); });