diff --git a/packages/event-handler/src/rest/BaseRouter.ts b/packages/event-handler/src/rest/BaseRouter.ts index 85b6aa44e..76a1dc45f 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 4326b7da6..8033b50d2 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); } } } @@ -590,4 +594,180 @@ describe('Class: BaseRouter', () => { expect(result.headers.get('Content-Type')).toBe('application/json'); }); }); + + describe('decorators error handling', () => { + 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', + }) + ); + }); + }); });