diff --git a/packages/event-handler/src/rest/Router.ts b/packages/event-handler/src/rest/Router.ts index 313fb88ffd..8d1766a2dc 100644 --- a/packages/event-handler/src/rest/Router.ts +++ b/packages/event-handler/src/rest/Router.ts @@ -4,7 +4,11 @@ import { isDevMode, } from '@aws-lambda-powertools/commons/utils/env'; import type { APIGatewayProxyResult, Context } from 'aws-lambda'; -import type { ResolveOptions } from '../types/index.js'; +import type { + HandlerResponse, + HttpStatusCode, + ResolveOptions, +} from '../types/index.js'; import type { ErrorConstructor, ErrorHandler, @@ -22,7 +26,6 @@ import { handlerResultToProxyResult, handlerResultToWebResponse, proxyEventToWebRequest, - webResponseToProxyResult, } from './converters.js'; import { ErrorHandlerRegistry } from './ErrorHandlerRegistry.js'; import { @@ -36,6 +39,7 @@ import { RouteHandlerRegistry } from './RouteHandlerRegistry.js'; import { composeMiddleware, isAPIGatewayProxyEvent, + isAPIGatewayProxyResult, isHttpMethod, } from './utils.js'; @@ -280,7 +284,9 @@ class Router { ...requestContext, scope: options?.scope, }); - return await webResponseToProxyResult(result); + const statusCode = + result instanceof Response ? result.status : result.statusCode; + return handlerResultToProxyResult(result, statusCode as HttpStatusCode); } } @@ -310,17 +316,31 @@ class Router { protected async handleError( error: Error, options: ErrorResolveOptions - ): Promise { + ): Promise { const handler = this.errorHandlerRegistry.resolve(error); if (handler !== null) { try { const { scope, ...reqCtx } = options; const body = await handler.apply(scope ?? this, [error, reqCtx]); + if (body instanceof Response || isAPIGatewayProxyResult(body)) { + return body; + } + if (!body.statusCode) { + if (error instanceof NotFoundError) { + body.statusCode = HttpErrorCodes.NOT_FOUND; + } else if (error instanceof MethodNotAllowedError) { + body.statusCode = HttpErrorCodes.METHOD_NOT_ALLOWED; + } + } return new Response(JSON.stringify(body), { - status: body.statusCode, + status: + (body.statusCode as number) ?? HttpErrorCodes.INTERNAL_SERVER_ERROR, headers: { 'Content-Type': 'application/json' }, }); } catch (handlerError) { + if (handlerError instanceof ServiceError) { + return await this.handleError(handlerError, options); + } return this.#defaultErrorHandler(handlerError as Error); } } diff --git a/packages/event-handler/src/rest/converters.ts b/packages/event-handler/src/rest/converters.ts index d82f2ada5c..30948b3d9b 100644 --- a/packages/event-handler/src/rest/converters.ts +++ b/packages/event-handler/src/rest/converters.ts @@ -1,6 +1,10 @@ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; -import type { CompressionOptions, HandlerResponse } from '../types/rest.js'; -import { COMPRESSION_ENCODING_TYPES } from './constants.js'; +import type { + CompressionOptions, + HandlerResponse, + HttpStatusCode, +} from '../types/rest.js'; +import { COMPRESSION_ENCODING_TYPES, HttpErrorCodes } from './constants.js'; import { isAPIGatewayProxyResult } from './utils.js'; /** @@ -181,10 +185,12 @@ export const handlerResultToWebResponse = ( * Handles APIGatewayProxyResult, Response objects, and plain objects. * * @param response - The handler response (APIGatewayProxyResult, Response, or plain object) + * @param statusCode - The response status code to return * @returns An API Gateway proxy result */ export const handlerResultToProxyResult = async ( - response: HandlerResponse + response: HandlerResponse, + statusCode: HttpStatusCode = HttpErrorCodes.OK ): Promise => { if (isAPIGatewayProxyResult(response)) { return response; @@ -193,7 +199,7 @@ export const handlerResultToProxyResult = async ( return await webResponseToProxyResult(response); } return { - statusCode: 200, + statusCode, body: JSON.stringify(response), headers: { 'content-type': 'application/json' }, isBase64Encoded: false, diff --git a/packages/event-handler/src/rest/errors.ts b/packages/event-handler/src/rest/errors.ts index f03421bc9c..80fa0cbc2a 100644 --- a/packages/event-handler/src/rest/errors.ts +++ b/packages/event-handler/src/rest/errors.ts @@ -1,4 +1,5 @@ -import type { ErrorResponse, HttpStatusCode } from '../types/rest.js'; +import type { JSONValue } from '@aws-lambda-powertools/commons/types'; +import type { HandlerResponse, HttpStatusCode } from '../types/rest.js'; import { HttpErrorCodes } from './constants.js'; export class RouteMatchingError extends Error { @@ -34,12 +35,14 @@ export abstract class ServiceError extends Error { this.details = details; } - toJSON(): ErrorResponse { + toJSON(): HandlerResponse { return { statusCode: this.statusCode, error: this.errorType, message: this.message, - ...(this.details && { details: this.details }), + ...(this.details && { + details: this.details as Record, + }), }; } } diff --git a/packages/event-handler/src/types/index.ts b/packages/event-handler/src/types/index.ts index 56e017c563..b32b78c70f 100644 --- a/packages/event-handler/src/types/index.ts +++ b/packages/event-handler/src/types/index.ts @@ -35,7 +35,6 @@ export type { CorsOptions, ErrorHandler, ErrorResolveOptions, - ErrorResponse, HandlerResponse, HttpMethod, HttpStatusCode, diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index 456a1dec9d..188d3ff29a 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -8,12 +8,6 @@ import type { Route } from '../rest/Route.js'; import type { Router } from '../rest/Router.js'; import type { ResolveOptions } from './common.js'; -type ErrorResponse = { - statusCode: HttpStatusCode; - error: string; - message: string; -}; - type RequestContext = { req: Request; event: APIGatewayProxyEvent; @@ -27,7 +21,7 @@ type ErrorResolveOptions = RequestContext & ResolveOptions; type ErrorHandler = ( error: T, reqCtx: RequestContext -) => Promise; +) => Promise; interface ErrorConstructor { new (...args: any[]): T; @@ -165,7 +159,6 @@ export type { CompiledRoute, CorsOptions, DynamicRoute, - ErrorResponse, ErrorConstructor, ErrorHandlerRegistryOptions, ErrorHandler, diff --git a/packages/event-handler/tests/unit/rest/Router/error-handling.test.ts b/packages/event-handler/tests/unit/rest/Router/error-handling.test.ts index d18778bcb0..9795117dc1 100644 --- a/packages/event-handler/tests/unit/rest/Router/error-handling.test.ts +++ b/packages/event-handler/tests/unit/rest/Router/error-handling.test.ts @@ -5,6 +5,7 @@ import { HttpErrorCodes, InternalServerError, MethodNotAllowedError, + NotFoundError, Router, } from '../../../../src/rest/index.js'; import { createTestEvent } from '../helpers.js'; @@ -49,7 +50,6 @@ describe('Class: Router - Error Handling', () => { const app = new Router(); app.notFound(async (error) => ({ - statusCode: HttpErrorCodes.NOT_FOUND, error: 'Not Found', message: `Custom: ${error.message}`, })); @@ -64,9 +64,9 @@ describe('Class: Router - Error Handling', () => { expect(result).toEqual({ statusCode: HttpErrorCodes.NOT_FOUND, body: JSON.stringify({ - statusCode: HttpErrorCodes.NOT_FOUND, error: 'Not Found', message: 'Custom: Route /nonexistent for method GET not found', + statusCode: HttpErrorCodes.NOT_FOUND, }), headers: { 'content-type': 'application/json' }, isBase64Encoded: false, @@ -78,7 +78,6 @@ describe('Class: Router - Error Handling', () => { const app = new Router(); app.methodNotAllowed(async (error) => ({ - statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED, error: 'Method Not Allowed', message: `Custom: ${error.message}`, })); @@ -94,9 +93,9 @@ describe('Class: Router - Error Handling', () => { expect(result).toEqual({ statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED, body: JSON.stringify({ - statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED, error: 'Method Not Allowed', message: 'Custom: POST not allowed', + statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED, }), headers: { 'content-type': 'application/json' }, isBase64Encoded: false, @@ -393,4 +392,178 @@ describe('Class: Router - Error Handling', () => { expect(body.hasEvent).toBe(true); expect(body.hasContext).toBe(true); }); + + it('handles returning a Response from the error handler', async () => { + // Prepare + const app = new Router(); + + app.errorHandler( + BadRequestError, + async () => + new Response( + JSON.stringify({ + foo: 'bar', + }), + { + status: HttpErrorCodes.BAD_REQUEST, + headers: { + 'content-type': 'application/json', + }, + } + ) + ); + + app.get('/test', () => { + throw new BadRequestError('test error'); + }); + + // Act + const result = await app.resolve(createTestEvent('/test', 'GET'), context); + + // Assess + expect(result).toEqual({ + statusCode: HttpErrorCodes.BAD_REQUEST, + body: JSON.stringify({ + foo: 'bar', + }), + headers: { 'content-type': 'application/json' }, + isBase64Encoded: false, + }); + }); + + it('handles returning an API Gateway Proxy result from the error handler', async () => { + // Prepare + const app = new Router(); + + app.errorHandler(BadRequestError, async () => ({ + statusCode: HttpErrorCodes.BAD_REQUEST, + body: JSON.stringify({ + foo: 'bar', + }), + })); + + app.get('/test', () => { + throw new BadRequestError('test error'); + }); + + // Act + const result = await app.resolve(createTestEvent('/test', 'GET'), context); + + // Assess + expect(result).toEqual({ + statusCode: HttpErrorCodes.BAD_REQUEST, + body: JSON.stringify({ + foo: 'bar', + }), + }); + }); + + it('handles returning a JSONObject from the error handler', async () => { + // Prepare + const app = new Router(); + + app.errorHandler(BadRequestError, async () => ({ foo: 'bar' })); + + app.get('/test', () => { + throw new BadRequestError('test error'); + }); + + // Act + const result = await app.resolve(createTestEvent('/test', 'GET'), context); + + // Assess + expect(result).toEqual({ + statusCode: HttpErrorCodes.INTERNAL_SERVER_ERROR, + body: JSON.stringify({ + foo: 'bar', + }), + headers: { 'content-type': 'application/json' }, + isBase64Encoded: false, + }); + }); + + it('handles throwing a built in NotFound error from the error handler', async () => { + // Prepare + const app = new Router(); + + app.errorHandler(BadRequestError, async () => { + throw new NotFoundError('This error is thrown from the error handler'); + }); + + app.get('/test', () => { + throw new BadRequestError('test error'); + }); + + // Act + const result = await app.resolve(createTestEvent('/test', 'GET'), context); + + // Assess + expect(result).toEqual({ + statusCode: HttpErrorCodes.NOT_FOUND, + body: JSON.stringify({ + statusCode: HttpErrorCodes.NOT_FOUND, + error: 'NotFoundError', + message: 'This error is thrown from the error handler', + }), + headers: { 'content-type': 'application/json' }, + isBase64Encoded: false, + }); + }); + + it('handles throwing a built in MethodNotAllowedError error from the error handler', async () => { + // Prepare + const app = new Router(); + + app.errorHandler(BadRequestError, async () => { + throw new MethodNotAllowedError( + 'This error is thrown from the error handler' + ); + }); + + app.get('/test', () => { + throw new BadRequestError('test error'); + }); + + // Act + const result = await app.resolve(createTestEvent('/test', 'GET'), context); + + // Assess + expect(result).toEqual({ + statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED, + body: JSON.stringify({ + statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED, + error: 'MethodNotAllowedError', + message: 'This error is thrown from the error handler', + }), + headers: { 'content-type': 'application/json' }, + isBase64Encoded: false, + }); + }); + + it('handles throwing a generic error from the error handler', async () => { + // Prepare + vi.stubEnv('POWERTOOLS_DEV', 'true'); + const app = new Router(); + + app.errorHandler(BadRequestError, async () => { + throw new Error('This error is thrown from the error handler'); + }); + + app.get('/test', () => { + throw new BadRequestError('test error'); + }); + + // Act + const result = await app.resolve(createTestEvent('/test', 'GET'), context); + + // Assess + expect(result.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); + const body = JSON.parse(result.body); + expect(body.error).toBe('Internal Server Error'); + expect(body.message).toBe('This error is thrown from the error handler'); + expect(body.stack).toBeDefined(); + expect(body.details).toEqual({ + errorName: 'Error', + }); + }); });