diff --git a/packages/event-handler/src/rest/Router.ts b/packages/event-handler/src/rest/Router.ts index cd6525d2c2..512ed2189b 100644 --- a/packages/event-handler/src/rest/Router.ts +++ b/packages/event-handler/src/rest/Router.ts @@ -1,7 +1,11 @@ import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import type streamWeb from 'node:stream/web'; -import type { GenericLogger } from '@aws-lambda-powertools/commons/types'; +import type { + GenericLogger, + JSONValue, +} from '@aws-lambda-powertools/commons/types'; +import { isRecord } from '@aws-lambda-powertools/commons/typeutils'; import { getStringFromEnv, isDevMode, @@ -457,19 +461,7 @@ class Router { ) { return body; } - if (!body.statusCode) { - if (error instanceof NotFoundError) { - body.statusCode = HttpStatusCodes.NOT_FOUND; - } else if (error instanceof MethodNotAllowedError) { - body.statusCode = HttpStatusCodes.METHOD_NOT_ALLOWED; - } - } - return new Response(JSON.stringify(body), { - status: - (body.statusCode as number) ?? - HttpStatusCodes.INTERNAL_SERVER_ERROR, - headers: { 'Content-Type': 'application/json' }, - }); + return this.#errorBodyToWebResponse(body, error); } catch (handlerError) { if (handlerError instanceof HttpError) { return await this.handleError(handlerError, options); @@ -488,6 +480,48 @@ class Router { return this.#defaultErrorHandler(error); } + /** + * Converts an error handler's response body to an HTTP Response object. + * + * If the body is a record object without a status code, sets the status code for + * NotFoundError (404) or MethodNotAllowedError (405). Uses the status code from + * the body if present, otherwise defaults to 500 Internal Server Error. + * + * @param body - The response body returned by the error handler, of type JSONValue + * @param error - The Error object associated with the response + */ + #errorBodyToWebResponse(body: JSONValue, error: Error): Response { + let status: number = HttpStatusCodes.INTERNAL_SERVER_ERROR; + + if (isRecord(body)) { + body.statusCode = body.statusCode ?? this.#getStatusCodeFromError(error); + status = (body.statusCode as number) ?? status; + } + + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + } + + /** + * Extracts the HTTP status code from an error instance. + * + * Maps specific error types to their corresponding HTTP status codes: + * - `NotFoundError` maps to 404 (NOT_FOUND) + * - `MethodNotAllowedError` maps to 405 (METHOD_NOT_ALLOWED) + * + * @param error - The error instance to extract the status code from + */ + #getStatusCodeFromError(error: Error): number | undefined { + if (error instanceof NotFoundError) { + return HttpStatusCodes.NOT_FOUND; + } + if (error instanceof MethodNotAllowedError) { + return HttpStatusCodes.METHOD_NOT_ALLOWED; + } + } + /** * Default error handler that returns a 500 Internal Server Error response. * In development mode, includes stack trace and error details. diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index c5652d0e70..99eddfdf1c 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -1,7 +1,7 @@ import type { Readable } from 'node:stream'; import type { GenericLogger, - JSONObject, + JSONValue, } from '@aws-lambda-powertools/commons/types'; import type { APIGatewayProxyEvent, @@ -81,7 +81,7 @@ type ExtendedAPIGatewayProxyResult = Omit & { type HandlerResponse = | Response - | JSONObject + | JSONValue | ExtendedAPIGatewayProxyResult | BinaryResult; diff --git a/packages/event-handler/tests/unit/rest/Router/basic-routing.test.ts b/packages/event-handler/tests/unit/rest/Router/basic-routing.test.ts index 01695dda7d..5d4eb2f51c 100644 --- a/packages/event-handler/tests/unit/rest/Router/basic-routing.test.ts +++ b/packages/event-handler/tests/unit/rest/Router/basic-routing.test.ts @@ -14,7 +14,7 @@ describe.each([ { version: 'V1', createEvent: createTestEvent }, { version: 'V2', createEvent: createTestEventV2 }, ])('Class: Router - Basic Routing ($version)', ({ createEvent }) => { - it.each([ + const httpMethods = [ ['GET', 'get'], ['POST', 'post'], ['PUT', 'put'], @@ -22,23 +22,60 @@ describe.each([ ['DELETE', 'delete'], ['HEAD', 'head'], ['OPTIONS', 'options'], - ])('routes %s requests', async (method, verb) => { - // Prepare - const app = new Router(); - ( - app[verb as Lowercase] as ( - path: string, - handler: RouteHandler - ) => void - )('/test', async () => ({ result: `${verb}-test` })); - // Act - const actual = await app.resolve(createEvent('/test', method), context); - // Assess - expect(actual.statusCode).toBe(200); - expect(actual.body).toBe(JSON.stringify({ result: `${verb}-test` })); - expect(actual.headers?.['content-type']).toBe('application/json'); - expect(actual.isBase64Encoded).toBe(false); - }); + ]; + it.each(httpMethods)( + 'routes %s requests with object response', + async (method, verb) => { + // Prepare + const app = new Router(); + ( + app[verb as Lowercase] as ( + path: string, + handler: RouteHandler + ) => void + )('/test', async () => ({ result: `${verb}-test` })); + + // Act + const actual = await app.resolve(createEvent('/test', method), context); + + // Assess + expect(actual.statusCode).toBe(200); + expect(actual.body).toBe(JSON.stringify({ result: `${verb}-test` })); + expect(actual.headers?.['content-type']).toBe('application/json'); + expect(actual.isBase64Encoded).toBe(false); + } + ); + + it.each(httpMethods)( + 'routes %s requests with array response', + async (method, verb) => { + // Prepare + const app = new Router(); + ( + app[verb as Lowercase] as ( + path: string, + handler: RouteHandler + ) => void + )('/test', async () => [ + { id: 1, result: `${verb}-test-1` }, + { id: 2, result: `${verb}-test-2` }, + ]); + + // Act + const actual = await app.resolve(createEvent('/test', method), context); + + // Assess + expect(actual.statusCode).toBe(200); + expect(actual.body).toBe( + JSON.stringify([ + { id: 1, result: `${verb}-test-1` }, + { id: 2, result: `${verb}-test-2` }, + ]) + ); + expect(actual.headers?.['content-type']).toBe('application/json'); + expect(actual.isBase64Encoded).toBe(false); + } + ); it.each([['CONNECT'], ['TRACE']])( 'throws MethodNotAllowedError for %s requests', diff --git a/packages/event-handler/tests/unit/rest/Router/decorators.test.ts b/packages/event-handler/tests/unit/rest/Router/decorators.test.ts index 44715314e3..f48fd194f4 100644 --- a/packages/event-handler/tests/unit/rest/Router/decorators.test.ts +++ b/packages/event-handler/tests/unit/rest/Router/decorators.test.ts @@ -1,11 +1,4 @@ import context from '@aws-lambda-powertools/testing-utils/context'; -import type { - APIGatewayProxyEvent, - APIGatewayProxyEventV2, - APIGatewayProxyResult, - APIGatewayProxyStructuredResultV2, - Context, -} from 'aws-lambda'; import { describe, expect, it } from 'vitest'; import { BadRequestError, @@ -17,124 +10,79 @@ import { } from '../../../../src/rest/index.js'; import type { RequestContext } from '../../../../src/types/rest.js'; import { + createHandler, + createHandlerWithScope, + createStreamHandler, createTestEvent, createTestEventV2, + createTestLambdaClass, createTrackingMiddleware, MockResponseStream, parseStreamOutput, } from '../helpers.js'; -const createHandler = (app: Router) => { - function handler( - event: APIGatewayProxyEvent, - context: Context - ): Promise; - function handler( - event: APIGatewayProxyEventV2, - context: Context - ): Promise; - function handler( - event: unknown, - context: Context - ): Promise; - function handler(event: unknown, context: Context) { - return app.resolve(event, context); - } - return handler; -}; - -const createHandlerWithScope = (app: Router, scope: unknown) => { - function handler( - event: APIGatewayProxyEvent, - context: Context - ): Promise; - function handler( - event: APIGatewayProxyEventV2, - context: Context - ): Promise; - function handler( - event: unknown, - context: Context - ): Promise; - function handler(event: unknown, context: Context) { - return app.resolve(event, context, { scope }); - } - return handler; -}; - -const createStreamHandler = - (app: Router, scope: unknown) => - (event: unknown, _context: Context, responseStream: MockResponseStream) => - app.resolveStream(event, _context, { scope, responseStream }); - describe.each([ { version: 'V1', createEvent: createTestEvent }, { version: 'V2', createEvent: createTestEventV2 }, ])('Class: Router - Decorators ($version)', ({ createEvent }) => { describe('decorators', () => { - const app = new Router(); - - class Lambda { - @app.get('/test') - public getTest() { - return { result: 'get-test' }; - } - - @app.post('/test') - public postTest() { - return { result: 'post-test' }; - } + const httpMethods = [ + ['GET', 'get'], + ['POST', 'post'], + ['PUT', 'put'], + ['PATCH', 'patch'], + ['DELETE', 'delete'], + ['HEAD', 'head'], + ['OPTIONS', 'options'], + ]; + it.each(httpMethods)( + 'routes %s requests with object response', + async (method, verb) => { + // Prepare + const app = new Router(); + const expected = { result: `${verb}-test` }; + const Lambda = createTestLambdaClass(app, expected); + const lambda = new Lambda(); - @app.put('/test') - public putTest() { - return { result: 'put-test' }; - } + // Act + const actual = await lambda.handler( + createTestEvent('/test', method), + context + ); - @app.patch('/test') - public patchTest() { - return { result: 'patch-test' }; + // Assess + expect(actual.statusCode).toBe(200); + expect(actual.body).toBe(JSON.stringify(expected)); + expect(actual.headers?.['content-type']).toBe('application/json'); + expect(actual.isBase64Encoded).toBe(false); } + ); - @app.delete('/test') - public deleteTest() { - return { result: 'delete-test' }; - } + it.each(httpMethods)( + 'routes %s requests with array response', + async (method, verb) => { + // Prepare + const app = new Router(); + const expected = [ + { id: 1, result: `${verb}-test-1` }, + { id: 2, result: `${verb}-test-2` }, + ]; + const Lambda = createTestLambdaClass(app, expected); + const lambda = new Lambda(); - @app.head('/test') - public headTest() { - return { result: 'head-test' }; - } + // Act + const actual = await lambda.handler( + createTestEvent('/test', method), + context + ); - @app.options('/test') - public optionsTest() { - return { result: 'options-test' }; + // Assess + expect(actual.statusCode).toBe(200); + expect(actual.body).toBe(JSON.stringify(expected)); + expect(actual.headers?.['content-type']).toBe('application/json'); + expect(actual.isBase64Encoded).toBe(false); } - - public handler = createHandler(app); - } - - it.each([ - ['GET', { result: 'get-test' }], - ['POST', { result: 'post-test' }], - ['PUT', { result: 'put-test' }], - ['PATCH', { result: 'patch-test' }], - ['DELETE', { result: 'delete-test' }], - ['HEAD', { result: 'head-test' }], - ['OPTIONS', { result: 'options-test' }], - ])('routes %s requests with decorators', async (method, expected) => { - // Prepare - const lambda = new Lambda(); - // Act - const actual = await lambda.handler( - createEvent('/test', method), - context - ); - // Assess - expect(actual.statusCode).toBe(200); - expect(actual.body).toBe(JSON.stringify(expected)); - expect(actual.headers?.['content-type']).toBe('application/json'); - expect(actual.isBase64Encoded).toBe(false); - }); + ); }); describe('decorators with middleware', () => { 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 00b83f7d02..d760242b61 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 @@ -500,6 +500,28 @@ describe.each([ }); }); + it('handles returning an array from the error handler', async () => { + // Prepare + const app = new Router(); + + app.errorHandler(BadRequestError, async () => ['error1', 'error2']); + + app.get('/test', () => { + throw new BadRequestError('test error'); + }); + + // Act + const result = await app.resolve(createTestEvent('/test', 'GET'), context); + + // Assess + expect(result).toEqual({ + statusCode: HttpStatusCodes.INTERNAL_SERVER_ERROR, + body: JSON.stringify(['error1', 'error2']), + 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(); diff --git a/packages/event-handler/tests/unit/rest/helpers.ts b/packages/event-handler/tests/unit/rest/helpers.ts index 470e12ab74..9f5e9e6fcc 100644 --- a/packages/event-handler/tests/unit/rest/helpers.ts +++ b/packages/event-handler/tests/unit/rest/helpers.ts @@ -1,4 +1,11 @@ -import type { APIGatewayProxyEvent, APIGatewayProxyEventV2 } from 'aws-lambda'; +import type { + APIGatewayProxyEvent, + APIGatewayProxyEventV2, + APIGatewayProxyResult, + APIGatewayProxyStructuredResultV2, + Context, +} from 'aws-lambda'; +import type { Router } from '../../../src/rest/Router.js'; import { HttpResponseStream } from '../../../src/rest/utils.js'; import type { HandlerResponse, Middleware } from '../../../src/types/rest.js'; @@ -160,3 +167,96 @@ export function parseStreamOutput(chunks: Buffer[]) { body: bodyBuffer.toString(), }; } + +// Create a handler function from the Router instance +export const createHandler = (app: Router) => { + function handler( + event: APIGatewayProxyEvent, + context: Context + ): Promise; + function handler( + event: APIGatewayProxyEventV2, + context: Context + ): Promise; + function handler( + event: unknown, + context: Context + ): Promise; + function handler(event: unknown, context: Context) { + return app.resolve(event, context); + } + return handler; +}; + +// Create a handler function from the Router instance with a custom scope +export const createHandlerWithScope = (app: Router, scope: unknown) => { + function handler( + event: APIGatewayProxyEvent, + context: Context + ): Promise; + function handler( + event: APIGatewayProxyEventV2, + context: Context + ): Promise; + function handler( + event: unknown, + context: Context + ): Promise; + function handler(event: unknown, context: Context) { + return app.resolve(event, context, { scope }); + } + return handler; +}; + +// Create a stream handler function from the Router instance with a custom scope +export const createStreamHandler = + (app: Router, scope: unknown) => + (event: unknown, _context: Context, responseStream: MockResponseStream) => + app.resolveStream(event, _context, { scope, responseStream }); + +// Create a test Lambda class with all HTTP method decorators +export const createTestLambdaClass = ( + app: Router, + expectedResponse: unknown +) => { + class Lambda { + @app.get('/test') + public getTest() { + return expectedResponse; + } + + @app.post('/test') + public postTest() { + return expectedResponse; + } + + @app.put('/test') + public putTest() { + return expectedResponse; + } + + @app.patch('/test') + public patchTest() { + return expectedResponse; + } + + @app.delete('/test') + public deleteTest() { + return expectedResponse; + } + + @app.head('/test') + public headTest() { + return expectedResponse; + } + + @app.options('/test') + public optionsTest() { + return expectedResponse; + } + + public handler = createHandler(app); + } + + return Lambda; +};