diff --git a/packages/event-handler/src/rest/Router.ts b/packages/event-handler/src/rest/Router.ts index f8ee13e7a9..32a95b3b54 100644 --- a/packages/event-handler/src/rest/Router.ts +++ b/packages/event-handler/src/rest/Router.ts @@ -6,7 +6,13 @@ import { getStringFromEnv, isDevMode, } from '@aws-lambda-powertools/commons/utils/env'; -import type { APIGatewayProxyResult, Context } from 'aws-lambda'; +import type { + APIGatewayProxyEvent, + APIGatewayProxyEventV2, + APIGatewayProxyResult, + APIGatewayProxyStructuredResultV2, + Context, +} from 'aws-lambda'; import type { HandlerResponse, ResolveOptions } from '../types/index.js'; import type { ErrorConstructor, @@ -24,15 +30,16 @@ import type { } from '../types/rest.js'; import { HttpStatusCodes, HttpVerbs } from './constants.js'; import { - handlerResultToProxyResult, handlerResultToWebResponse, proxyEventToWebRequest, - webHeadersToApiGatewayV1Headers, + webHeadersToApiGatewayHeaders, + webResponseToProxyResult, } from './converters.js'; import { ErrorHandlerRegistry } from './ErrorHandlerRegistry.js'; import { HttpError, - InternalServerError, + InvalidEventError, + InvalidHttpMethodError, MethodNotAllowedError, NotFoundError, } from './errors.js'; @@ -41,9 +48,9 @@ import { RouteHandlerRegistry } from './RouteHandlerRegistry.js'; import { composeMiddleware, HttpResponseStream, - isAPIGatewayProxyEvent, + isAPIGatewayProxyEventV1, + isAPIGatewayProxyEventV2, isExtendedAPIGatewayProxyResult, - isHttpMethod, resolvePrefixedPath, } from './utils.js'; @@ -202,26 +209,35 @@ class Router { event: unknown, context: Context, options?: ResolveOptions - ): Promise { - if (!isAPIGatewayProxyEvent(event)) { + ): Promise { + if (!isAPIGatewayProxyEventV1(event) && !isAPIGatewayProxyEventV2(event)) { this.logger.error( 'Received an event that is not compatible with this resolver' ); - throw new InternalServerError(); + throw new InvalidEventError(); } - const method = event.requestContext.httpMethod.toUpperCase(); - if (!isHttpMethod(method)) { - this.logger.error(`HTTP method ${method} is not supported.`); - // We can't throw a MethodNotAllowedError outside the try block as it - // will be converted to an internal server error by the API Gateway runtime - return { - statusCode: HttpStatusCodes.METHOD_NOT_ALLOWED, - body: '', - }; - } + const responseType = isAPIGatewayProxyEventV2(event) ? 'v2' : 'v1'; - const req = proxyEventToWebRequest(event); + let req: Request; + try { + req = proxyEventToWebRequest(event); + } catch (err) { + if (err instanceof InvalidHttpMethodError) { + this.logger.error(err); + // We can't throw a MethodNotAllowedError outside the try block as it + // will be converted to an internal server error by the API Gateway runtime + return { + event, + context, + req: new Request('https://invalid'), + res: new Response('', { status: HttpStatusCodes.METHOD_NOT_ALLOWED }), + params: {}, + responseType, + }; + } + throw err; + } const requestContext: RequestContext = { event, @@ -229,11 +245,13 @@ class Router { req, // this response should be overwritten by the handler, if it isn't // it means something went wrong with the middleware chain - res: new Response('', { status: 500 }), + res: new Response('', { status: HttpStatusCodes.INTERNAL_SERVER_ERROR }), params: {}, + responseType, }; try { + const method = req.method as HttpMethod; const path = new URL(req.url).pathname as Path; const route = this.routeRegistry.resolve(method, path); @@ -255,6 +273,7 @@ class Router { : route.handler.bind(options.scope); const handlerResult = await handler(reqCtx); + reqCtx.res = handlerResultToWebResponse( handlerResult, reqCtx.res.headers @@ -277,13 +296,25 @@ class Router { }); // middleware result takes precedence to allow short-circuiting - return middlewareResult ?? requestContext.res; + if (middlewareResult !== undefined) { + requestContext.res = handlerResultToWebResponse( + middlewareResult, + requestContext.res.headers + ); + } + + return requestContext; } catch (error) { this.logger.debug(`There was an error processing the request: ${error}`); - return this.handleError(error as Error, { + const res = await this.handleError(error as Error, { ...requestContext, scope: options?.scope, }); + requestContext.res = handlerResultToWebResponse( + res, + requestContext.res.headers + ); + return requestContext; } } @@ -296,15 +327,30 @@ class Router { * @param event - The Lambda event to resolve * @param context - The Lambda context * @param options - Optional resolve options for scope binding - * @returns An API Gateway proxy result + * @returns An API Gateway proxy result (V1 or V2 format depending on event version) */ + public async resolve( + event: APIGatewayProxyEvent, + context: Context, + options?: ResolveOptions + ): Promise; + public async resolve( + event: APIGatewayProxyEventV2, + context: Context, + options?: ResolveOptions + ): Promise; public async resolve( event: unknown, context: Context, options?: ResolveOptions - ): Promise { - const result = await this.#resolve(event, context, options); - return handlerResultToProxyResult(result); + ): Promise; + public async resolve( + event: unknown, + context: Context, + options?: ResolveOptions + ): Promise { + const reqCtx = await this.#resolve(event, context, options); + return webResponseToProxyResult(reqCtx.res, reqCtx.responseType); } /** @@ -321,31 +367,33 @@ class Router { context: Context, options: ResolveStreamOptions ): Promise { - const result = await this.#resolve(event, context, options); - await this.#streamHandlerResponse(result, options.responseStream); + const reqCtx = await this.#resolve(event, context, options); + await this.#streamHandlerResponse(reqCtx, options.responseStream); } /** * Streams a handler response to the Lambda response stream. * Converts the response to a web response and pipes it through the stream. * - * @param response - The handler response to stream + * @param reqCtx - The request context containing the response to stream * @param responseStream - The Lambda response stream to write to */ async #streamHandlerResponse( - response: HandlerResponse, + reqCtx: RequestContext, responseStream: ResponseStream ) { - const webResponse = handlerResultToWebResponse(response); - const { headers } = webHeadersToApiGatewayV1Headers(webResponse.headers); + const { headers } = webHeadersToApiGatewayHeaders( + reqCtx.res.headers, + reqCtx.responseType + ); const resStream = HttpResponseStream.from(responseStream, { - statusCode: webResponse.status, + statusCode: reqCtx.res.status, headers, }); - if (webResponse.body) { + if (reqCtx.res.body) { const nodeStream = Readable.fromWeb( - webResponse.body as streamWeb.ReadableStream + reqCtx.res.body as streamWeb.ReadableStream ); await pipeline(nodeStream, resStream); } else { diff --git a/packages/event-handler/src/rest/converters.ts b/packages/event-handler/src/rest/converters.ts index 73afeb4c02..10b687c2d3 100644 --- a/packages/event-handler/src/rest/converters.ts +++ b/packages/event-handler/src/rest/converters.ts @@ -1,17 +1,26 @@ import { Readable } from 'node:stream'; import type streamWeb from 'node:stream/web'; -import { isString } from '@aws-lambda-powertools/commons/typeutils'; -import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import type { + APIGatewayProxyEvent, + APIGatewayProxyEventV2, + APIGatewayProxyResult, + APIGatewayProxyStructuredResultV2, +} from 'aws-lambda'; import type { CompressionOptions, ExtendedAPIGatewayProxyResult, ExtendedAPIGatewayProxyResultBody, HandlerResponse, - HttpStatusCode, + ResponseType, + ResponseTypeMap, + V1Headers, } from '../types/rest.js'; -import { COMPRESSION_ENCODING_TYPES, HttpStatusCodes } from './constants.js'; +import { COMPRESSION_ENCODING_TYPES } from './constants.js'; +import { InvalidHttpMethodError } from './errors.js'; import { + isAPIGatewayProxyEventV2, isExtendedAPIGatewayProxyResult, + isHttpMethod, isNodeReadableStream, isWebReadableStream, } from './utils.js'; @@ -38,16 +47,16 @@ const createBody = (body: string | null, isBase64Encoded: boolean) => { * @param event - The API Gateway proxy event * @returns A Web API Request object */ -const proxyEventToWebRequest = (event: APIGatewayProxyEvent): Request => { +const proxyEventV1ToWebRequest = (event: APIGatewayProxyEvent): Request => { const { httpMethod, path } = event; const { domainName } = event.requestContext; const headers = new Headers(); - for (const [name, value] of Object.entries(event.headers)) { + for (const [name, value] of Object.entries(event.headers ?? {})) { if (value !== undefined) headers.set(name, value); } - for (const [name, values] of Object.entries(event.multiValueHeaders)) { + for (const [name, values] of Object.entries(event.multiValueHeaders ?? {})) { for (const value of values ?? []) { const headerValue = headers.get(name); if (!headerValue?.includes(value)) { @@ -81,8 +90,68 @@ const proxyEventToWebRequest = (event: APIGatewayProxyEvent): Request => { }; /** - * Converts Web API Headers to API Gateway v1 headers format. - * Splits multi-value headers by comma and organizes them into separate objects. + * Converts an API Gateway V2 proxy event to a Web API Request object. + * + * @param event - The API Gateway V2 proxy event + * @returns A Web API Request object + */ +const proxyEventV2ToWebRequest = (event: APIGatewayProxyEventV2): Request => { + const { rawPath, rawQueryString } = event; + const { + http: { method }, + domainName, + } = event.requestContext; + + const headers = new Headers(); + for (const [name, value] of Object.entries(event.headers)) { + if (value !== undefined) headers.set(name, value); + } + + if (Array.isArray(event.cookies)) { + headers.set('Cookie', event.cookies.join('; ')); + } + + const hostname = headers.get('Host') ?? domainName; + const protocol = headers.get('X-Forwarded-Proto') ?? 'https'; + + const url = rawQueryString + ? `${protocol}://${hostname}${rawPath}?${rawQueryString}` + : `${protocol}://${hostname}${rawPath}`; + + return new Request(url, { + method, + headers, + body: createBody(event.body ?? null, event.isBase64Encoded), + }); +}; + +/** + * Converts an API Gateway proxy event (V1 or V2) to a Web API Request object. + * Automatically detects the event version and calls the appropriate converter. + * + * @param event - The API Gateway proxy event (V1 or V2) + * @returns A Web API Request object + */ +const proxyEventToWebRequest = ( + event: APIGatewayProxyEvent | APIGatewayProxyEventV2 +): Request => { + if (isAPIGatewayProxyEventV2(event)) { + const method = event.requestContext.http.method.toUpperCase(); + if (!isHttpMethod(method)) { + throw new InvalidHttpMethodError(method); + } + return proxyEventV2ToWebRequest(event); + } + const method = event.requestContext.httpMethod.toUpperCase(); + if (!isHttpMethod(method)) { + throw new InvalidHttpMethodError(method); + } + return proxyEventV1ToWebRequest(event); +}; + +/** + * Converts Web API Headers to API Gateway V1 headers format. + * Splits multi-value headers by comma or semicolon and organizes them into separate objects. * * @param webHeaders - The Web API Headers object * @returns Object containing headers and multiValueHeaders @@ -92,8 +161,12 @@ const webHeadersToApiGatewayV1Headers = (webHeaders: Headers) => { const multiValueHeaders: Record> = {}; for (const [key, value] of webHeaders.entries()) { - const values = value.split(',').map((v) => v.trimStart()); - if (values.length > 1) { + const values = value.split(/[;,]/).map((v) => v.trimStart()); + + if (headers[key]) { + multiValueHeaders[key] = [headers[key], ...values]; + delete headers[key]; + } else if (values.length > 1) { multiValueHeaders[key] = values; } else { headers[key] = value; @@ -107,12 +180,42 @@ const webHeadersToApiGatewayV1Headers = (webHeaders: Headers) => { }; /** - * Converts a Web API Response object to an API Gateway proxy result. + * Converts Web API Headers to API Gateway V2 headers format. + * + * @param webHeaders - The Web API Headers object + * @returns Object containing headers + */ +const webHeadersToApiGatewayV2Headers = (webHeaders: Headers) => { + const headers: Record = {}; + + for (const [key, value] of webHeaders.entries()) { + headers[key] = value; + } + + return { headers }; +}; + +const webHeadersToApiGatewayHeaders = ( + webHeaders: Headers, + responseType: T +): T extends 'v1' ? V1Headers : { headers: Record } => { + if (responseType === 'v1') { + return webHeadersToApiGatewayV1Headers(webHeaders) as T extends 'v1' + ? V1Headers + : { headers: Record }; + } + return webHeadersToApiGatewayV2Headers(webHeaders) as T extends 'v1' + ? V1Headers + : { headers: Record }; +}; + +/** + * Converts a Web API Response object to an API Gateway V1 proxy result. * * @param response - The Web API Response object - * @returns An API Gateway proxy result + * @returns An API Gateway V1 proxy result */ -const webResponseToProxyResult = async ( +const webResponseToProxyResultV1 = async ( response: Response ): Promise => { const { headers, multiValueHeaders } = webHeadersToApiGatewayV1Headers( @@ -156,13 +259,77 @@ const webResponseToProxyResult = async ( return result; }; +/** + * Converts a Web API Response object to an API Gateway V2 proxy result. + * + * @param response - The Web API Response object + * @returns An API Gateway V2 proxy result + */ +const webResponseToProxyResultV2 = async ( + response: Response +): Promise => { + const headers: Record = {}; + const cookies: string[] = []; + + for (const [key, value] of response.headers.entries()) { + if (key.toLowerCase() === 'set-cookie') { + cookies.push(...value.split(',').map((v) => v.trimStart())); + } else { + headers[key] = value; + } + } + + const contentEncoding = response.headers.get( + 'content-encoding' + ) as CompressionOptions['encoding']; + let body: string; + let isBase64Encoded = false; + + if ( + contentEncoding && + [ + COMPRESSION_ENCODING_TYPES.GZIP, + COMPRESSION_ENCODING_TYPES.DEFLATE, + ].includes(contentEncoding) + ) { + const buffer = await response.arrayBuffer(); + body = Buffer.from(buffer).toString('base64'); + isBase64Encoded = true; + } else { + body = await response.text(); + } + + const result: APIGatewayProxyStructuredResultV2 = { + statusCode: response.status, + headers, + body, + isBase64Encoded, + }; + + if (cookies.length > 0) { + result.cookies = cookies; + } + + return result; +}; + +const webResponseToProxyResult = ( + response: Response, + responseType: T +): Promise => { + if (responseType === 'v1') { + return webResponseToProxyResultV1(response) as Promise; + } + return webResponseToProxyResultV2(response) as Promise; +}; + /** * Adds headers from an ExtendedAPIGatewayProxyResult to a Headers object. * * @param headers - The Headers object to mutate * @param response - The response containing headers to add * @remarks This function mutates the headers object by adding entries from - * response.headers and response.multiValueHeaders + * response.headers, response.multiValueHeaders, and response.cookies */ function addProxyEventHeaders( headers: Headers, @@ -181,6 +348,12 @@ function addProxyEventHeaders( headers.append(key, String(value)); } } + + if (response.cookies && response.cookies.length > 0) { + for (const cookie of response.cookies) { + headers.append('Set-Cookie', cookie); + } + } } /** @@ -226,48 +399,6 @@ const handlerResultToWebResponse = ( return Response.json(response, { headers }); }; -/** - * Converts a handler response to an API Gateway proxy result. - * 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 - */ -const handlerResultToProxyResult = async ( - response: HandlerResponse, - statusCode: HttpStatusCode = HttpStatusCodes.OK -): Promise => { - if (isExtendedAPIGatewayProxyResult(response)) { - if (isString(response.body)) { - return { - ...response, - body: response.body, - }; - } - if ( - isNodeReadableStream(response.body) || - isWebReadableStream(response.body) - ) { - const nodeStream = bodyToNodeStream(response.body); - return { - ...response, - isBase64Encoded: true, - body: await nodeStreamToBase64(nodeStream), - }; - } - } - if (response instanceof Response) { - return await webResponseToProxyResult(response); - } - return { - statusCode, - body: JSON.stringify(response), - headers: { 'content-type': 'application/json' }, - isBase64Encoded: false, - }; -}; - /** * Converts various body types to a Node.js Readable stream. * Handles Node.js streams, web streams, and string bodies. @@ -285,26 +416,10 @@ const bodyToNodeStream = (body: ExtendedAPIGatewayProxyResultBody) => { return Readable.from(Buffer.from(body as string)); }; -/** - * Converts a Node.js Readable stream to a base64 encoded string. - * Handles both Buffer and string chunks by converting all to Buffers. - * - * @param stream - The Node.js Readable stream to convert - * @returns A Promise that resolves to a base64 encoded string - */ -async function nodeStreamToBase64(stream: Readable) { - const chunks: Buffer[] = []; - for await (const chunk of stream) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - return Buffer.concat(chunks).toString('base64'); -} - export { proxyEventToWebRequest, webResponseToProxyResult, handlerResultToWebResponse, - handlerResultToProxyResult, bodyToNodeStream, - webHeadersToApiGatewayV1Headers, + webHeadersToApiGatewayHeaders, }; diff --git a/packages/event-handler/src/rest/errors.ts b/packages/event-handler/src/rest/errors.ts index a190843e8a..66cc410d28 100644 --- a/packages/event-handler/src/rest/errors.ts +++ b/packages/event-handler/src/rest/errors.ts @@ -173,10 +173,26 @@ class ServiceUnavailableError extends HttpError { } } +class InvalidEventError extends Error { + constructor(message?: string) { + super(message); + this.name = 'InvalidEventError'; + } +} + +class InvalidHttpMethodError extends Error { + constructor(method: string) { + super(`HTTP method ${method} is not supported.`); + this.name = 'InvalidEventError'; + } +} + export { BadRequestError, ForbiddenError, InternalServerError, + InvalidEventError, + InvalidHttpMethodError, MethodNotAllowedError, NotFoundError, ParameterValidationError, diff --git a/packages/event-handler/src/rest/index.ts b/packages/event-handler/src/rest/index.ts index c6d0000093..4bbdb73edb 100644 --- a/packages/event-handler/src/rest/index.ts +++ b/packages/event-handler/src/rest/index.ts @@ -1,6 +1,5 @@ export { HttpStatusCodes, HttpVerbs } from './constants.js'; export { - handlerResultToProxyResult, handlerResultToWebResponse, proxyEventToWebRequest, webResponseToProxyResult, @@ -22,7 +21,8 @@ export { export { Router } from './Router.js'; export { composeMiddleware, - isAPIGatewayProxyEvent, + isAPIGatewayProxyEventV1, + isAPIGatewayProxyEventV2, isExtendedAPIGatewayProxyResult, isHttpMethod, } from './utils.js'; diff --git a/packages/event-handler/src/rest/utils.ts b/packages/event-handler/src/rest/utils.ts index e040a3759a..1b7c197171 100644 --- a/packages/event-handler/src/rest/utils.ts +++ b/packages/event-handler/src/rest/utils.ts @@ -4,7 +4,7 @@ import { isRegExp, isString, } from '@aws-lambda-powertools/commons/typeutils'; -import type { APIGatewayProxyEvent } from 'aws-lambda'; +import type { APIGatewayProxyEvent, APIGatewayProxyEventV2 } from 'aws-lambda'; import type { CompiledRoute, ExtendedAPIGatewayProxyResult, @@ -75,14 +75,14 @@ export function validatePathPattern(path: Path): ValidationResult { } /** - * Type guard to check if the provided event is an API Gateway Proxy event. + * Type guard to check if the provided event is an API Gateway Proxy V1 event. * * We use this function to ensure that the event is an object and has the * required properties without adding a dependency. * * @param event - The incoming event to check */ -export const isAPIGatewayProxyEvent = ( +export const isAPIGatewayProxyEventV1 = ( event: unknown ): event is APIGatewayProxyEvent => { if (!isRecord(event)) return false; @@ -104,6 +104,32 @@ export const isAPIGatewayProxyEvent = ( ); }; +/** + * Type guard to check if the provided event is an API Gateway Proxy V2 event. + * + * @param event - The incoming event to check + */ +export const isAPIGatewayProxyEventV2 = ( + event: unknown +): event is APIGatewayProxyEventV2 => { + if (!isRecord(event)) return false; + return ( + event.version === '2.0' && + isString(event.routeKey) && + isString(event.rawPath) && + isString(event.rawQueryString) && + isRecord(event.headers) && + isRecord(event.requestContext) && + typeof event.isBase64Encoded === 'boolean' && + (event.body === undefined || isString(event.body)) && + (event.pathParameters === undefined || isRecord(event.pathParameters)) && + (event.queryStringParameters === undefined || + isRecord(event.queryStringParameters)) && + (event.stageVariables === undefined || isRecord(event.stageVariables)) && + (event.cookies === undefined || Array.isArray(event.cookies)) + ); +}; + export const isHttpMethod = (method: string): method is HttpMethod => { return Object.keys(HttpVerbs).includes(method); }; diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index 4fa17c36a2..c1b81c516f 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -5,7 +5,9 @@ import type { } from '@aws-lambda-powertools/commons/types'; import type { APIGatewayProxyEvent, + APIGatewayProxyEventV2, APIGatewayProxyResult, + APIGatewayProxyStructuredResultV2, Context, } from 'aws-lambda'; import type { HttpStatusCodes, HttpVerbs } from '../rest/constants.js'; @@ -13,12 +15,20 @@ import type { Route } from '../rest/Route.js'; import type { HttpResponseStream } from '../rest/utils.js'; import type { ResolveOptions } from './common.js'; +type ResponseType = 'v1' | 'v2'; + +type ResponseTypeMap = { + v1: APIGatewayProxyResult; + v2: APIGatewayProxyStructuredResultV2; +}; + type RequestContext = { req: Request; - event: APIGatewayProxyEvent; + event: APIGatewayProxyEvent | APIGatewayProxyEventV2; context: Context; res: Response; params: Record; + responseType: ResponseType; }; type ErrorResolveOptions = RequestContext & ResolveOptions; @@ -63,6 +73,7 @@ type ExtendedAPIGatewayProxyResultBody = string | Readable | ReadableStream; type ExtendedAPIGatewayProxyResult = Omit & { body: ExtendedAPIGatewayProxyResultBody; + cookies?: string[]; }; type HandlerResponse = Response | JSONObject | ExtendedAPIGatewayProxyResult; @@ -126,6 +137,11 @@ type ResponseStream = InstanceType & { _onBeforeFirstWrite?: (write: (data: Uint8Array | string) => void) => void; }; +type V1Headers = { + headers: Record; + multiValueHeaders: Record; +}; + /** * Object to pass to the {@link Router.resolveStream | `Router.resolveStream()`} method. */ @@ -230,6 +246,8 @@ export type { Middleware, Path, RequestContext, + ResponseType, + ResponseTypeMap, RestRouterOptions, RouteHandler, ResolveStreamOptions, @@ -240,4 +258,5 @@ export type { ValidationResult, CompressionOptions, NextFunction, + V1Headers, }; 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 3fdd5b0954..555c33c6e8 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 @@ -1,15 +1,18 @@ import context from '@aws-lambda-powertools/testing-utils/context'; import { describe, expect, it, vi } from 'vitest'; +import { InvalidEventError } from '../../../../src/rest/errors.js'; import { HttpStatusCodes, HttpVerbs, - InternalServerError, Router, } from '../../../../src/rest/index.js'; import type { HttpMethod, RouteHandler } from '../../../../src/types/rest.js'; -import { createTestEvent } from '../helpers.js'; +import { createTestEvent, createTestEventV2 } from '../helpers.js'; -describe('Class: Router - Basic Routing', () => { +describe.each([ + { version: 'V1', createEvent: createTestEvent }, + { version: 'V2', createEvent: createTestEventV2 }, +])('Class: Router - Basic Routing ($version)', ({ createEvent }) => { it.each([ ['GET', 'get'], ['POST', 'post'], @@ -28,14 +31,12 @@ describe('Class: Router - Basic Routing', () => { ) => void )('/test', async () => ({ result: `${verb}-test` })); // Act - const actual = await app.resolve(createTestEvent('/test', method), context); + const actual = await app.resolve(createEvent('/test', method), context); // Assess - expect(actual).toEqual({ - statusCode: 200, - body: JSON.stringify({ result: `${verb}-test` }), - headers: { 'content-type': 'application/json' }, - isBase64Encoded: false, - }); + 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([['CONNECT'], ['TRACE']])( @@ -45,13 +46,10 @@ describe('Class: Router - Basic Routing', () => { const app = new Router(); // Act & Assess - const result = await app.resolve( - createTestEvent('/test', method), - context - ); + const result = await app.resolve(createEvent('/test', method), context); expect(result.statusCode).toBe(HttpStatusCodes.METHOD_NOT_ALLOWED); - expect(result.body).toEqual(''); + expect(result.body ?? '').toBe(''); } ); @@ -65,29 +63,30 @@ describe('Class: Router - Basic Routing', () => { // Act const getResult = await app.resolve( - createTestEvent('/test', HttpVerbs.GET), + createEvent('/test', HttpVerbs.GET), context ); const postResult = await app.resolve( - createTestEvent('/test', HttpVerbs.POST), + createEvent('/test', HttpVerbs.POST), context ); // Assess - const expectedResult = { - statusCode: 200, - body: JSON.stringify({ result: 'route-test' }), - headers: { 'content-type': 'application/json' }, - isBase64Encoded: false, - }; - expect(getResult).toEqual(expectedResult); - expect(postResult).toEqual(expectedResult); + expect(getResult.statusCode).toBe(200); + expect(getResult.body).toBe(JSON.stringify({ result: 'route-test' })); + expect(getResult.headers?.['content-type']).toBe('application/json'); + expect(getResult.isBase64Encoded).toBe(false); + + expect(postResult.statusCode).toBe(200); + expect(postResult.body).toBe(JSON.stringify({ result: 'route-test' })); + expect(postResult.headers?.['content-type']).toBe('application/json'); + expect(postResult.isBase64Encoded).toBe(false); }); it('passes request, event, and context to functional route handlers', async () => { // Prepare const app = new Router(); - const testEvent = createTestEvent('/test', 'GET'); + const testEvent = createEvent('/test', 'GET'); app.get('/test', (reqCtx) => { return { @@ -99,7 +98,7 @@ describe('Class: Router - Basic Routing', () => { // Act const result = await app.resolve(testEvent, context); - const actual = JSON.parse(result.body); + const actual = JSON.parse(result.body ?? '{}'); // Assess expect(actual.hasRequest).toBe(true); @@ -107,14 +106,14 @@ describe('Class: Router - Basic Routing', () => { expect(actual.hasContext).toBe(true); }); - it('throws an internal server error for non-API Gateway events', () => { + it('throws an invalid event error for non-API Gateway events', () => { // Prepare const app = new Router(); const nonApiGatewayEvent = { Records: [] }; // SQS-like event // Act & Assess expect(app.resolve(nonApiGatewayEvent, context)).rejects.toThrowError( - InternalServerError + InvalidEventError ); }); @@ -132,17 +131,17 @@ describe('Class: Router - Basic Routing', () => { // Act const createResult = await app.resolve( - createTestEvent('/todos', 'POST'), + createEvent('/todos', 'POST'), context ); const getResult = await app.resolve( - createTestEvent('/todos/1', 'GET'), + createEvent('/todos/1', 'GET'), context ); // Assess - expect(JSON.parse(createResult.body).actualPath).toBe('/todos'); - expect(JSON.parse(getResult.body).actualPath).toBe('/todos/1'); + expect(JSON.parse(createResult.body ?? '{}').actualPath).toBe('/todos'); + expect(JSON.parse(getResult.body ?? '{}').actualPath).toBe('/todos/1'); }); it('routes to the included router when using split routers', async () => { @@ -169,20 +168,22 @@ describe('Class: Router - Basic Routing', () => { app.includeRouter(todoRouter, { prefix: '/todos' }); // Act - const rootResult = await app.resolve(createTestEvent('/', 'GET'), context); + const rootResult = await app.resolve(createEvent('/', 'GET'), context); const listTodosResult = await app.resolve( - createTestEvent('/todos', 'GET'), + createEvent('/todos', 'GET'), context ); const notFoundResult = await app.resolve( - createTestEvent('/non-existent', 'GET'), + createEvent('/non-existent', 'GET'), context ); // Assert - expect(JSON.parse(rootResult.body).api).toEqual('root'); - expect(JSON.parse(listTodosResult.body).api).toEqual('listTodos'); - expect(JSON.parse(notFoundResult.body).error).toEqual('Route not found'); + expect(JSON.parse(rootResult.body ?? '{}').api).toEqual('root'); + expect(JSON.parse(listTodosResult.body ?? '{}').api).toEqual('listTodos'); + expect(JSON.parse(notFoundResult.body ?? '{}').error).toEqual( + 'Route not found' + ); expect(consoleLogSpy).toHaveBeenNthCalledWith(1, 'app middleware'); expect(consoleLogSpy).toHaveBeenNthCalledWith(2, 'todoRouter middleware'); expect(consoleWarnSpy).toHaveBeenNthCalledWith( @@ -213,9 +214,62 @@ describe('Class: Router - Basic Routing', () => { }); // Act - const result = await app.resolve(createTestEvent(path, method), context); + const result = await app.resolve(createEvent(path, method), context); + + // Assess + expect(JSON.parse(result.body ?? '{}').api).toEqual(expectedApi); + }); +}); + +describe('Class: Router - V1 Multivalue Headers Support', () => { + it('handles ExtendedAPIGatewayProxyResult with multiValueHeaders field', async () => { + // Prepare + const app = new Router(); + app.get('/test', () => ({ + statusCode: 200, + body: JSON.stringify({ message: 'success' }), + headers: { 'content-type': 'application/json' }, + multiValueHeaders: { 'set-cookie': ['session=abc123', 'theme=dark'] }, + })); + + // Act + const result = await app.resolve(createTestEvent('/test', 'GET'), context); // Assess - expect(JSON.parse(result.body).api).toEqual(expectedApi); + expect(result).toEqual({ + statusCode: 200, + body: JSON.stringify({ message: 'success' }), + headers: { 'content-type': 'application/json' }, + multiValueHeaders: { 'set-cookie': ['session=abc123', 'theme=dark'] }, + isBase64Encoded: false, + }); + }); +}); + +describe('Class: Router - V2 Cookies Support', () => { + it('handles ExtendedAPIGatewayProxyResult with cookies field', async () => { + // Prepare + const app = new Router(); + app.get('/test', () => ({ + statusCode: 200, + body: JSON.stringify({ message: 'success' }), + headers: { 'content-type': 'application/json' }, + cookies: ['session=abc123', 'theme=dark'], + })); + + // Act + const result = await app.resolve( + createTestEventV2('/test', 'GET'), + context + ); + + // Assess + expect(result).toEqual({ + statusCode: 200, + body: JSON.stringify({ message: 'success' }), + headers: { 'content-type': 'application/json' }, + cookies: ['session=abc123', 'theme=dark'], + isBase64Encoded: false, + }); }); }); 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 c08914b55d..44715314e3 100644 --- a/packages/event-handler/tests/unit/rest/Router/decorators.test.ts +++ b/packages/event-handler/tests/unit/rest/Router/decorators.test.ts @@ -1,5 +1,11 @@ import context from '@aws-lambda-powertools/testing-utils/context'; -import type { Context } from 'aws-lambda'; +import type { + APIGatewayProxyEvent, + APIGatewayProxyEventV2, + APIGatewayProxyResult, + APIGatewayProxyStructuredResultV2, + Context, +} from 'aws-lambda'; import { describe, expect, it } from 'vitest'; import { BadRequestError, @@ -12,24 +18,59 @@ import { import type { RequestContext } from '../../../../src/types/rest.js'; import { createTestEvent, + createTestEventV2, createTrackingMiddleware, MockResponseStream, parseStreamOutput, } from '../helpers.js'; -const createHandler = (app: Router) => (event: unknown, _context: Context) => - app.resolve(event, _context); - -const createHandlerWithScope = - (app: Router, scope: unknown) => (event: unknown, _context: Context) => - app.resolve(event, _context, { scope }); +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('Class: Router - Decorators', () => { +describe.each([ + { version: 'V1', createEvent: createTestEvent }, + { version: 'V2', createEvent: createTestEventV2 }, +])('Class: Router - Decorators ($version)', ({ createEvent }) => { describe('decorators', () => { const app = new Router(); @@ -85,16 +126,14 @@ describe('Class: Router - Decorators', () => { const lambda = new Lambda(); // Act const actual = await lambda.handler( - createTestEvent('/test', method), + createEvent('/test', method), context ); // Assess - expect(actual).toEqual({ - statusCode: 200, - body: JSON.stringify(expected), - headers: { 'content-type': 'application/json' }, - isBase64Encoded: false, - }); + 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); }); }); @@ -124,7 +163,7 @@ describe('Class: Router - Decorators', () => { const handler = lambda.handler.bind(lambda); // Act - const result = await handler(createTestEvent('/test', 'GET'), context); + const result = await handler(createEvent('/test', 'GET'), context); // Assess expect(executionOrder).toEqual([ @@ -204,7 +243,7 @@ describe('Class: Router - Decorators', () => { // Act const result = await lambda.handler( - createTestEvent('/test', method), + createEvent('/test', method), context ); @@ -249,10 +288,7 @@ describe('Class: Router - Decorators', () => { const lambda = new Lambda(); // Act - const result = await lambda.handler( - createTestEvent('/test', 'GET'), - context - ); + const result = await lambda.handler(createEvent('/test', 'GET'), context); // Assess expect(result).toEqual({ @@ -290,10 +326,7 @@ describe('Class: Router - Decorators', () => { const handler = lambda.handler.bind(lambda); // Act - const result = await handler( - createTestEvent('/nonexistent', 'GET'), - context - ); + const result = await handler(createEvent('/nonexistent', 'GET'), context); // Assess expect(result).toEqual({ @@ -333,10 +366,7 @@ describe('Class: Router - Decorators', () => { const lambda = new Lambda(); // Act - const result = await lambda.handler( - createTestEvent('/test', 'GET'), - context - ); + const result = await lambda.handler(createEvent('/test', 'GET'), context); // Assess expect(result).toEqual({ @@ -379,7 +409,7 @@ describe('Class: Router - Decorators', () => { const handler = lambda.handler.bind(lambda); // Act - const result = await handler(createTestEvent('/test', 'GET'), context); + const result = await handler(createEvent('/test', 'GET'), context); // Assess expect(result).toEqual({ @@ -399,7 +429,7 @@ describe('Class: Router - Decorators', () => { it('passes request, event, and context to decorator route handlers', async () => { // Prepare const app = new Router(); - const testEvent = createTestEvent('/test', 'GET'); + const testEvent = createEvent('/test', 'GET'); class Lambda { @app.get('/test') @@ -418,7 +448,7 @@ describe('Class: Router - Decorators', () => { // Act const result = await lambda.handler(testEvent, context); - const actual = JSON.parse(result.body); + const actual = JSON.parse(result.body ?? '{}'); // Assess expect(actual.hasRequest).toBe(true); 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 3c7aaea176..6e3ac82e66 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 @@ -8,9 +8,12 @@ import { NotFoundError, Router, } from '../../../../src/rest/index.js'; -import { createTestEvent } from '../helpers.js'; +import { createTestEvent, createTestEventV2 } from '../helpers.js'; -describe('Class: Router - Error Handling', () => { +describe.each([ + { version: 'V1', createEvent: createTestEvent }, + { version: 'V2', createEvent: createTestEventV2 }, +])('Class: Router - Error Handling ($version)', ({ createEvent }) => { beforeEach(() => { vi.unstubAllEnvs(); }); @@ -30,7 +33,7 @@ describe('Class: Router - Error Handling', () => { }); // Act - const result = await app.resolve(createTestEvent('/test', 'GET'), context); + const result = await app.resolve(createEvent('/test', 'GET'), context); // Assess expect(result).toEqual({ @@ -56,7 +59,7 @@ describe('Class: Router - Error Handling', () => { // Act const result = await app.resolve( - createTestEvent('/nonexistent', 'GET'), + createEvent('/nonexistent', 'GET'), context ); @@ -87,7 +90,7 @@ describe('Class: Router - Error Handling', () => { }); // Act - const result = await app.resolve(createTestEvent('/test', 'GET'), context); + const result = await app.resolve(createEvent('/test', 'GET'), context); // Assess expect(result).toEqual({ @@ -116,14 +119,19 @@ describe('Class: Router - Error Handling', () => { }); // Act - const result = await app.resolve(createTestEvent('/test', 'GET'), context); + const result = await app.resolve(createEvent('/test', 'GET'), context); // Assess - expect(result.statusCode).toBe(HttpStatusCodes.INTERNAL_SERVER_ERROR); - const body = JSON.parse(result.body); - expect(body.statusCode).toBe(HttpStatusCodes.INTERNAL_SERVER_ERROR); - expect(body.error).toBe('Internal Server Error'); - expect(body.message).toBe('Internal Server Error'); + expect(result).toEqual({ + statusCode: HttpStatusCodes.INTERNAL_SERVER_ERROR, + body: JSON.stringify({ + statusCode: HttpStatusCodes.INTERNAL_SERVER_ERROR, + error: 'Internal Server Error', + message: 'Internal Server Error', + }), + headers: { 'content-type': 'application/json' }, + isBase64Encoded: false, + }); }); it('uses default handling when no error handler is registered', async () => { @@ -136,14 +144,19 @@ describe('Class: Router - Error Handling', () => { }); // Act - const result = await app.resolve(createTestEvent('/test', 'GET'), context); + const result = await app.resolve(createEvent('/test', 'GET'), context); // Assess - expect(result.statusCode).toBe(HttpStatusCodes.INTERNAL_SERVER_ERROR); - const body = JSON.parse(result.body); - expect(body.statusCode).toBe(HttpStatusCodes.INTERNAL_SERVER_ERROR); - expect(body.error).toBe('Internal Server Error'); - expect(body.message).toBe('Internal Server Error'); + expect(result).toEqual({ + statusCode: HttpStatusCodes.INTERNAL_SERVER_ERROR, + body: JSON.stringify({ + statusCode: HttpStatusCodes.INTERNAL_SERVER_ERROR, + error: 'Internal Server Error', + message: 'Internal Server Error', + }), + headers: { 'content-type': 'application/json' }, + isBase64Encoded: false, + }); }); it('calls most specific error handler when multiple handlers match', async () => { @@ -167,7 +180,7 @@ describe('Class: Router - Error Handling', () => { }); // Act - const result = await app.resolve(createTestEvent('/test', 'GET'), context); + const result = await app.resolve(createEvent('/test', 'GET'), context); // Assess expect(result).toEqual({ @@ -191,7 +204,7 @@ describe('Class: Router - Error Handling', () => { }); // Act - const result = await app.resolve(createTestEvent('/test', 'GET'), context); + const result = await app.resolve(createEvent('/test', 'GET'), context); // Assess expect(result).toEqual({ @@ -216,16 +229,19 @@ describe('Class: Router - Error Handling', () => { }); // Act - const result = await app.resolve(createTestEvent('/test', 'GET'), context); + const result = await app.resolve(createEvent('/test', 'GET'), context); // Assess - expect(result.statusCode).toBe(HttpStatusCodes.INTERNAL_SERVER_ERROR); - const body = JSON.parse(result.body); - expect(body.statusCode).toBe(HttpStatusCodes.INTERNAL_SERVER_ERROR); - expect(body.error).toBe('Internal Server Error'); - expect(body.message).toBe('Internal Server Error'); - expect(body.stack).toBeUndefined(); - expect(body.details).toBeUndefined(); + expect(result).toEqual({ + statusCode: HttpStatusCodes.INTERNAL_SERVER_ERROR, + body: JSON.stringify({ + statusCode: HttpStatusCodes.INTERNAL_SERVER_ERROR, + error: 'Internal Server Error', + message: 'Internal Server Error', + }), + headers: { 'content-type': 'application/json' }, + isBase64Encoded: false, + }); }); it('shows error details in development mode', async () => { @@ -238,11 +254,14 @@ describe('Class: Router - Error Handling', () => { }); // Act - const result = await app.resolve(createTestEvent('/test', 'GET'), context); + const result = await app.resolve(createEvent('/test', 'GET'), context); // Assess expect(result.statusCode).toBe(HttpStatusCodes.INTERNAL_SERVER_ERROR); - const body = JSON.parse(result.body); + expect(result.headers).toEqual({ 'content-type': 'application/json' }); + expect(result.isBase64Encoded).toBe(false); + + const body = JSON.parse(result.body ?? '{}'); expect(body.statusCode).toBe(HttpStatusCodes.INTERNAL_SERVER_ERROR); expect(body.error).toBe('Internal Server Error'); expect(body.message).toBe('debug error details'); @@ -273,12 +292,9 @@ describe('Class: Router - Error Handling', () => { }); // Act - const badResult = await app.resolve( - createTestEvent('/bad', 'GET'), - context - ); + const badResult = await app.resolve(createEvent('/bad', 'GET'), context); const methodResult = await app.resolve( - createTestEvent('/method', 'GET'), + createEvent('/method', 'GET'), context ); @@ -329,7 +345,7 @@ describe('Class: Router - Error Handling', () => { }); // Act - const result = await app.resolve(createTestEvent('/test', 'GET'), context); + const result = await app.resolve(createEvent('/test', 'GET'), context); // Assess expect(result).toEqual({ @@ -359,7 +375,7 @@ describe('Class: Router - Error Handling', () => { }); // Act - const result = await app.resolve(createTestEvent('/test', 'GET'), context); + const result = await app.resolve(createEvent('/test', 'GET'), context); // Assess expect(result.headers?.['content-type']).toBe('application/json'); @@ -368,7 +384,7 @@ describe('Class: Router - Error Handling', () => { it('passes request, event, and context to functional error handlers', async () => { // Prepare const app = new Router(); - const testEvent = createTestEvent('/test', 'GET'); + const testEvent = createEvent('/test', 'GET'); app.errorHandler(BadRequestError, async (error, reqCtx) => ({ statusCode: HttpStatusCodes.BAD_REQUEST, @@ -385,7 +401,7 @@ describe('Class: Router - Error Handling', () => { // Act const result = await app.resolve(testEvent, context); - const body = JSON.parse(result.body); + const body = JSON.parse(result.body ?? '{}'); // Assess expect(body.hasRequest).toBe(true); @@ -418,7 +434,7 @@ describe('Class: Router - Error Handling', () => { }); // Act - const result = await app.resolve(createTestEvent('/test', 'GET'), context); + const result = await app.resolve(createEvent('/test', 'GET'), context); // Assess expect(result).toEqual({ @@ -447,11 +463,13 @@ describe('Class: Router - Error Handling', () => { }); // Act - const result = await app.resolve(createTestEvent('/test', 'GET'), context); + const result = await app.resolve(createEvent('/test', 'GET'), context); // Assess expect(result).toEqual({ statusCode: HttpStatusCodes.BAD_REQUEST, + headers: { 'content-type': 'application/json' }, + isBase64Encoded: false, body: JSON.stringify({ foo: 'bar', }), @@ -469,7 +487,7 @@ describe('Class: Router - Error Handling', () => { }); // Act - const result = await app.resolve(createTestEvent('/test', 'GET'), context); + const result = await app.resolve(createEvent('/test', 'GET'), context); // Assess expect(result).toEqual({ @@ -495,7 +513,7 @@ describe('Class: Router - Error Handling', () => { }); // Act - const result = await app.resolve(createTestEvent('/test', 'GET'), context); + const result = await app.resolve(createEvent('/test', 'GET'), context); // Assess expect(result).toEqual({ @@ -525,7 +543,7 @@ describe('Class: Router - Error Handling', () => { }); // Act - const result = await app.resolve(createTestEvent('/test', 'GET'), context); + const result = await app.resolve(createEvent('/test', 'GET'), context); // Assess expect(result).toEqual({ @@ -554,11 +572,14 @@ describe('Class: Router - Error Handling', () => { }); // Act - const result = await app.resolve(createTestEvent('/test', 'GET'), context); + const result = await app.resolve(createEvent('/test', 'GET'), context); // Assess expect(result.statusCode).toBe(HttpStatusCodes.INTERNAL_SERVER_ERROR); - const body = JSON.parse(result.body); + expect(result.headers).toEqual({ 'content-type': 'application/json' }); + expect(result.isBase64Encoded).toBe(false); + + 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(); @@ -567,3 +588,32 @@ describe('Class: Router - Error Handling', () => { }); }); }); +describe('Class: Router - proxyEventToWebRequest Error Handling', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('re-throws non-InvalidHttpMethodError from proxyEventToWebRequest', async () => { + // Prepare + vi.doMock('../../../../src/rest/converters.js', async () => { + const actual = await vi.importActual< + typeof import('../../../../src/rest/converters.js') + >('../../../../src/rest/converters.js'); + return { + ...actual, + proxyEventToWebRequest: vi.fn(() => { + throw new TypeError('Unexpected error'); + }), + }; + }); + + const { Router } = await import('../../../../src/rest/Router.js'); + const app = new Router(); + app.get('/test', () => ({ message: 'success' })); + + // Act & Assess + await expect( + app.resolve(createTestEvent('/test', 'GET'), context) + ).rejects.toThrow('Unexpected error'); + }); +}); diff --git a/packages/event-handler/tests/unit/rest/Router/middleware.test.ts b/packages/event-handler/tests/unit/rest/Router/middleware.test.ts index bcdc41c16b..1c5f9c8c96 100644 --- a/packages/event-handler/tests/unit/rest/Router/middleware.test.ts +++ b/packages/event-handler/tests/unit/rest/Router/middleware.test.ts @@ -12,6 +12,7 @@ import { createNoNextMiddleware, createReturningMiddleware, createTestEvent, + createTestEventV2, createThrowingMiddleware, createTrackingMiddleware, } from '../helpers.js'; @@ -595,8 +596,8 @@ describe('Class: Router - Middleware', () => { // Assess expect(result.statusCode).toBe(200); - expect(result.isBase64Encoded).toBe(true); - expect(result.body).toBe(Buffer.from(testData).toString('base64')); + expect(result.isBase64Encoded).toBe(false); + expect(result.body).toEqual(testData); }); it('handles middleware returning ExtendedAPIGatewayProxyResult with web stream body', async () => { @@ -625,8 +626,34 @@ describe('Class: Router - Middleware', () => { // Assess expect(result.statusCode).toBe(200); - expect(result.isBase64Encoded).toBe(true); - expect(result.body).toBe(Buffer.from(testData).toString('base64')); + expect(result.isBase64Encoded).toBe(false); + expect(result.body).toEqual(testData); + }); + + it('handles middleware returning v2 proxy event with cookies', async () => { + // Prepare + const app = new Router(); + + app.use(async () => ({ + statusCode: 200, + body: JSON.stringify({ message: 'middleware response' }), + cookies: ['session=abc123', 'theme=dark'], + })); + + app.get('/test', () => ({ success: true })); + + // Act + const result = await app.resolve( + createTestEventV2('/test', 'GET'), + context + ); + + // Assess + expect(result.statusCode).toBe(200); + expect(result.body).toBe( + JSON.stringify({ message: 'middleware response' }) + ); + expect(result.cookies).toEqual(['session=abc123', 'theme=dark']); }); }); diff --git a/packages/event-handler/tests/unit/rest/Router/streaming.test.ts b/packages/event-handler/tests/unit/rest/Router/streaming.test.ts index 46f29e834c..e5c92a834e 100644 --- a/packages/event-handler/tests/unit/rest/Router/streaming.test.ts +++ b/packages/event-handler/tests/unit/rest/Router/streaming.test.ts @@ -5,11 +5,15 @@ import { UnauthorizedError } from '../../../../src/rest/errors.js'; import { Router } from '../../../../src/rest/index.js'; import { createTestEvent, + createTestEventV2, MockResponseStream, parseStreamOutput, } from '../helpers.js'; -describe('Class: Router - Streaming', () => { +describe.each([ + { version: 'V1', createEvent: createTestEvent }, + { version: 'V2', createEvent: createTestEventV2 }, +])('Class: Router - Streaming ($version)', ({ createEvent }) => { it('streams a simple JSON response', async () => { // Prepare const app = new Router(); @@ -19,7 +23,7 @@ describe('Class: Router - Streaming', () => { const responseStream = new MockResponseStream(); // Act - await app.resolveStream(createTestEvent('/test', 'GET'), context, { + await app.resolveStream(createEvent('/test', 'GET'), context, { responseStream, }); @@ -43,7 +47,7 @@ describe('Class: Router - Streaming', () => { const responseStream = new MockResponseStream(); // Act - await app.resolveStream(createTestEvent('/test', 'GET'), context, { + await app.resolveStream(createEvent('/test', 'GET'), context, { responseStream, }); @@ -59,7 +63,7 @@ describe('Class: Router - Streaming', () => { const responseStream = new MockResponseStream(); // Act - await app.resolveStream(createTestEvent('/nonexistent', 'GET'), context, { + await app.resolveStream(createEvent('/nonexistent', 'GET'), context, { responseStream, }); @@ -85,7 +89,7 @@ describe('Class: Router - Streaming', () => { const responseStream = new MockResponseStream(); // Act - await app.resolveStream(createTestEvent('/test', 'GET'), context, { + await app.resolveStream(createEvent('/test', 'GET'), context, { responseStream, }); @@ -106,7 +110,7 @@ describe('Class: Router - Streaming', () => { const responseStream = new MockResponseStream(); // Act - await app.resolveStream(createTestEvent('/test', 'GET'), context, { + await app.resolveStream(createEvent('/test', 'GET'), context, { responseStream, }); @@ -133,7 +137,7 @@ describe('Class: Router - Streaming', () => { const responseStream = new MockResponseStream(); // Act - await app.resolveStream(createTestEvent('/test', 'GET'), context, { + await app.resolveStream(createEvent('/test', 'GET'), context, { responseStream, }); @@ -179,7 +183,7 @@ describe('Class: Router - Streaming', () => { const responseStream = new MockResponseStream(); // Act - await app.resolveStream(createTestEvent('/test', 'GET'), context, { + await app.resolveStream(createEvent('/test', 'GET'), context, { responseStream, }); @@ -196,7 +200,7 @@ describe('Class: Router - Streaming', () => { const responseStream = new MockResponseStream(); // Act - await app.resolveStream(createTestEvent('/test', 'GET'), context, { + await app.resolveStream(createEvent('/test', 'GET'), context, { responseStream, }); @@ -213,7 +217,7 @@ describe('Class: Router - Streaming', () => { const responseStream = new MockResponseStream(); // Act - await app.resolveStream(createTestEvent('/test', 'GET'), context, { + await app.resolveStream(createEvent('/test', 'GET'), context, { responseStream, }); @@ -237,7 +241,7 @@ describe('Class: Router - Streaming', () => { // Act & Assess await expect( - app.resolveStream(createTestEvent('/test', 'GET'), context, { + app.resolveStream(createEvent('/test', 'GET'), context, { responseStream, }) ).rejects.toThrow('Stream error'); @@ -257,7 +261,7 @@ describe('Class: Router - Streaming', () => { // Act await app.resolveStream( - createTestEvent('/users/123/posts/456', 'GET'), + createEvent('/users/123/posts/456', 'GET'), context, { responseStream } ); @@ -280,7 +284,7 @@ describe('Class: Router - Streaming', () => { const responseStream = new MockResponseStream(); // Act - await app.resolveStream(createTestEvent('/test', 'GET'), context, { + await app.resolveStream(createEvent('/test', 'GET'), context, { responseStream, }); @@ -322,7 +326,7 @@ describe('Class: Router - Streaming', () => { const responseStream = new MockResponseStream(); // Act - await app.resolveStream(createTestEvent('/test', 'GET'), context, { + await app.resolveStream(createEvent('/test', 'GET'), context, { responseStream, }); diff --git a/packages/event-handler/tests/unit/rest/converters.test.ts b/packages/event-handler/tests/unit/rest/converters.test.ts index 41be5e7d69..a9e1c78ec3 100644 --- a/packages/event-handler/tests/unit/rest/converters.test.ts +++ b/packages/event-handler/tests/unit/rest/converters.test.ts @@ -1,18 +1,19 @@ import { Readable } from 'node:stream'; import { describe, expect, it } from 'vitest'; -import { bodyToNodeStream } from '../../../src/rest/converters.js'; import { - handlerResultToProxyResult, + bodyToNodeStream, + webHeadersToApiGatewayHeaders, +} from '../../../src/rest/converters.js'; +import { handlerResultToWebResponse, proxyEventToWebRequest, webResponseToProxyResult, } from '../../../src/rest/index.js'; -import { createTestEvent } from './helpers.js'; +import { createTestEvent, createTestEventV2 } from './helpers.js'; describe('Converters', () => { - describe('proxyEventToWebRequest', () => { + describe('proxyEventToWebRequest (V1)', () => { const baseEvent = createTestEvent('/test', 'GET'); - it('converts basic GET request', () => { // Prepare & Act const request = proxyEventToWebRequest(baseEvent); @@ -321,8 +322,8 @@ describe('Converters', () => { const event = { ...baseEvent, headers: { - 'Valid-Header': 'value', - 'Undefined-Header': undefined, + valid: 'value', + undefined: undefined, }, }; @@ -331,8 +332,209 @@ describe('Converters', () => { // Assess expect(request).toBeInstanceOf(Request); - expect(request.headers.get('Valid-Header')).toBe('value'); - expect(request.headers.get('Undefined-Header')).toBe(null); + expect(request.headers.get('valid')).toBe('value'); + expect(request.headers.has('undefined')).toBe(false); + }); + + it('handles null headers and multiValueHeaders', () => { + // Prepare + const event = { + ...baseEvent, + headers: null, + multiValueHeaders: null, + }; + + // Act + // The type in the aws-lambda package is incorrect, headers and multiValueHeaders + // can be null if you use the test functionality in the AWS console + // @ts-expect-error - testing null headers fallback + const request = proxyEventToWebRequest(event); + + // Assess + expect(request).toBeInstanceOf(Request); + expect(request.url).toBe('https://api.example.com/test'); + }); + }); + + describe('proxyEventToWebRequest (V2)', () => { + it('converts basic GET request', () => { + // Prepare + const event = createTestEventV2('/test', 'GET'); + + // Act + const request = proxyEventToWebRequest(event); + + // Assess + expect(request).toBeInstanceOf(Request); + expect(request.method).toBe('GET'); + expect(request.url).toBe('https://api.example.com/test'); + expect(request.body).toBe(null); + }); + + it('handles query string', () => { + // Prepare + const event = { + ...createTestEventV2('/test', 'GET'), + rawQueryString: 'name=john&age=25', + }; + + // Act + const request = proxyEventToWebRequest(event); + + // Assess + expect(request).toBeInstanceOf(Request); + expect(request.url).toBe('https://api.example.com/test?name=john&age=25'); + }); + + it('handles empty query string', () => { + // Prepare + const event = createTestEventV2('/test', 'GET'); + + // Act + const request = proxyEventToWebRequest(event); + + // Assess + expect(request).toBeInstanceOf(Request); + expect(request.url).toBe('https://api.example.com/test'); + }); + + it('uses Host header over domainName', () => { + // Prepare + const event = { + ...createTestEventV2('/test', 'GET'), + headers: { Host: 'custom.example.com' }, + }; + + // Act + const request = proxyEventToWebRequest(event); + + // Assess + expect(request).toBeInstanceOf(Request); + expect(request.url).toBe('https://custom.example.com/test'); + }); + + it('uses X-Forwarded-Proto header for protocol', () => { + // Prepare + const event = createTestEventV2('/test', 'GET', { + 'X-Forwarded-Proto': 'http', + }); + + // Act + const request = proxyEventToWebRequest(event); + + // Assess + expect(request).toBeInstanceOf(Request); + expect(request.url).toBe('http://api.example.com/test'); + }); + + it('handles POST request with string body', () => { + // Prepare + const event = { + ...createTestEventV2('/test', 'POST'), + body: '{"key":"value"}', + headers: { 'Content-Type': 'application/json' }, + }; + + // Act + const request = proxyEventToWebRequest(event); + + // Assess + expect(request).toBeInstanceOf(Request); + expect(request.method).toBe('POST'); + expect(request.text()).resolves.toBe('{"key":"value"}'); + expect(request.headers.get('Content-Type')).toBe('application/json'); + }); + + it('decodes base64 encoded body', () => { + // Prepare + const originalText = 'Hello World'; + const base64Text = Buffer.from(originalText).toString('base64'); + const event = { + ...createTestEventV2('/test', 'POST'), + body: base64Text, + isBase64Encoded: true, + }; + + // Act + const request = proxyEventToWebRequest(event); + + // Assess + expect(request).toBeInstanceOf(Request); + expect(request.text()).resolves.toBe(originalText); + }); + + it('handles cookies array', () => { + // Prepare + const event = { + ...createTestEventV2('/test', 'GET'), + cookies: ['session=abc123', 'user=john'], + }; + + // Act + const request = proxyEventToWebRequest(event); + + // Assess + expect(request).toBeInstanceOf(Request); + expect(request.headers.get('Cookie')).toBe('session=abc123; user=john'); + }); + + it('handles undefined cookies', () => { + // Prepare + const event = createTestEventV2('/test', 'GET'); + + // Act + const request = proxyEventToWebRequest(event); + + // Assess + expect(request).toBeInstanceOf(Request); + expect(request.headers.has('Cookie')).toBe(false); + }); + + it('handles headers', () => { + // Prepare + const event = createTestEventV2('/test', 'GET', { + Authorization: 'Bearer token123', + 'User-Agent': 'test-agent', + }); + + // Act + const request = proxyEventToWebRequest(event); + + // Assess + expect(request).toBeInstanceOf(Request); + expect(request.headers.get('Authorization')).toBe('Bearer token123'); + expect(request.headers.get('User-Agent')).toBe('test-agent'); + }); + + it('skips undefined header values', () => { + // Prepare + const event = { + ...createTestEventV2('/test', 'GET'), + headers: { + valid: 'value', + undefined: undefined, + }, + }; + + // Act + const request = proxyEventToWebRequest(event); + + // Assess + expect(request).toBeInstanceOf(Request); + expect(request.headers.get('valid')).toBe('value'); + expect(request.headers.has('undefined')).toBe(false); + }); + + it('handles undefined body', () => { + // Prepare + const event = createTestEventV2('/test', 'GET'); + + // Act + const request = proxyEventToWebRequest(event); + + // Assess + expect(request).toBeInstanceOf(Request); + expect(request.body).toBe(null); }); }); @@ -347,7 +549,7 @@ describe('Converters', () => { }); // Act - const result = await webResponseToProxyResult(response); + const result = await webResponseToProxyResult(response, 'v1'); // Assess expect(result.statusCode).toBe(200); @@ -364,7 +566,7 @@ describe('Converters', () => { }); // Act - const result = await webResponseToProxyResult(response); + const result = await webResponseToProxyResult(response, 'v1'); // Assess expect(result.statusCode).toBe(201); @@ -379,13 +581,13 @@ describe('Converters', () => { const response = new Response('Hello', { status: 200, headers: { - 'Set-Cookie': 'cookie1=value1, cookie2=value2', + 'Set-Cookie': 'cookie1=value1; cookie2=value2', 'Content-type': 'application/json', }, }); // Act - const result = await webResponseToProxyResult(response); + const result = await webResponseToProxyResult(response, 'v1'); // Assess expect(result.headers).toEqual({ 'content-type': 'application/json' }); @@ -405,7 +607,7 @@ describe('Converters', () => { }); // Act - const result = await webResponseToProxyResult(response); + const result = await webResponseToProxyResult(response, 'v1'); // Assess expect(result.headers).toEqual({ @@ -421,7 +623,7 @@ describe('Converters', () => { const response = new Response('Not Found', { status: 404 }); // Act - const result = await webResponseToProxyResult(response); + const result = await webResponseToProxyResult(response, 'v1'); // Assess expect(result.statusCode).toBe(404); @@ -432,7 +634,7 @@ describe('Converters', () => { const response = new Response(null, { status: 204 }); // Act - const result = await webResponseToProxyResult(response); + const result = await webResponseToProxyResult(response, 'v1'); // Assess expect(result.statusCode).toBe(204); @@ -449,7 +651,7 @@ describe('Converters', () => { }); // Act - const result = await webResponseToProxyResult(response); + const result = await webResponseToProxyResult(response, 'v1'); // Assess expect(result.isBase64Encoded).toBe(true); @@ -459,129 +661,123 @@ describe('Converters', () => { }); }); - describe('handlerResultToProxyResult', () => { - it('returns ExtendedAPIGatewayProxyResult with string body as-is', async () => { + describe('webResponseToProxyResult - V2', () => { + it('converts basic Response to API Gateway V2 result', async () => { // Prepare - const proxyResult = { - statusCode: 200, - body: 'test', - headers: { 'content-type': 'text/plain' }, - isBase64Encoded: false, - }; + const response = new Response('Hello World', { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); // Act - const result = await handlerResultToProxyResult(proxyResult); + const result = await webResponseToProxyResult(response, 'v2'); // Assess - expect(result).toEqual({ - statusCode: 200, - body: 'test', - headers: { 'content-type': 'text/plain' }, - isBase64Encoded: false, - }); + expect(result.statusCode).toBe(200); + expect(result.body).toBe('Hello World'); + expect(result.isBase64Encoded).toBe(false); + expect(result.headers).toEqual({ 'content-type': 'application/json' }); + expect(result.cookies).toBeUndefined(); }); - it('converts ExtendedAPIGatewayProxyResult with Node.js Buffer stream body to base64', async () => { + it('handles single-value headers', async () => { // Prepare - const stream = Readable.from([ - Buffer.from('Hello'), - Buffer.from(' '), - Buffer.from('World'), - ]); - const proxyResult = { - statusCode: 200, - body: stream, - headers: { 'content-type': 'application/octet-stream' }, - isBase64Encoded: false, - }; + const response = new Response('Hello', { + status: 201, + headers: { 'content-type': 'text/plain', 'x-custom': 'value' }, + }); // Act - const result = await handlerResultToProxyResult(proxyResult); + const result = await webResponseToProxyResult(response, 'v2'); // Assess - expect(result.statusCode).toBe(200); - expect(result.isBase64Encoded).toBe(true); - expect(result.body).toBe(Buffer.from('Hello World').toString('base64')); + expect(result.statusCode).toBe(201); expect(result.headers).toEqual({ - 'content-type': 'application/octet-stream', + 'content-type': 'text/plain', + 'x-custom': 'value', }); }); - it('converts ExtendedAPIGatewayProxyResult with Node.js string stream body to base64', async () => { + it('extracts Set-Cookie headers into cookies array', async () => { // Prepare - const stream = Readable.from(['Hello', ' ', 'World']); - const proxyResult = { - statusCode: 200, - body: stream, - headers: { 'content-type': 'application/octet-stream' }, - isBase64Encoded: false, - }; + const response = new Response('Hello', { + status: 200, + headers: { + 'Set-Cookie': 'session=abc, theme=dark', + 'content-type': 'application/json', + }, + }); // Act - const result = await handlerResultToProxyResult(proxyResult); + const result = await webResponseToProxyResult(response, 'v2'); // Assess - expect(result.statusCode).toBe(200); - expect(result.isBase64Encoded).toBe(true); - expect(result.body).toBe(Buffer.from('Hello World').toString('base64')); - expect(result.headers).toEqual({ - 'content-type': 'application/octet-stream', - }); + expect(result.headers).toEqual({ 'content-type': 'application/json' }); + expect(result.cookies).toEqual(['session=abc', 'theme=dark']); }); - it('converts ExtendedAPIGatewayProxyResult with web stream body to base64', async () => { + it('handles multiple Set-Cookie headers', async () => { // Prepare - const webStream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('Hello')); - controller.enqueue(new TextEncoder().encode(' World')); - controller.close(); + const response = new Response('Hello', { + status: 200, + headers: { + 'Set-Cookie': 'cookie1=value1, cookie2=value2, cookie3=value3', }, }); - const proxyResult = { - statusCode: 200, - body: webStream, - headers: { 'content-type': 'application/octet-stream' }, - isBase64Encoded: false, - }; // Act - const result = await handlerResultToProxyResult(proxyResult); + const result = await webResponseToProxyResult(response, 'v2'); // Assess - expect(result.statusCode).toBe(200); - expect(result.isBase64Encoded).toBe(true); - expect(result.body).toBe(Buffer.from('Hello World').toString('base64')); - expect(result.headers).toEqual({ - 'content-type': 'application/octet-stream', - }); + expect(result.cookies).toEqual([ + 'cookie1=value1', + 'cookie2=value2', + 'cookie3=value3', + ]); + expect(result.headers?.['set-cookie']).toBeUndefined(); }); - it('converts Response object', async () => { + it('handles different status codes', async () => { // Prepare - const response = new Response('Hello', { status: 201 }); + const response = new Response('Not Found', { status: 404 }); // Act - const result = await handlerResultToProxyResult(response); + const result = await webResponseToProxyResult(response, 'v2'); // Assess - expect(result.statusCode).toBe(201); - expect(result.body).toBe('Hello'); - expect(result.isBase64Encoded).toBe(false); + expect(result.statusCode).toBe(404); + expect(result.body).toBe('Not Found'); }); - it('converts plain object to JSON', async () => { + it('handles empty response body', async () => { // Prepare - const obj = { message: 'success', data: [1, 2, 3] }; + const response = new Response(null, { status: 204 }); // Act - const result = await handlerResultToProxyResult(obj); + const result = await webResponseToProxyResult(response, 'v2'); // Assess - expect(result.statusCode).toBe(200); - expect(result.body).toBe(JSON.stringify(obj)); - expect(result.headers).toEqual({ 'content-type': 'application/json' }); - expect(result.isBase64Encoded).toBe(false); + expect(result.statusCode).toBe(204); + expect(result.body).toBe(''); + }); + + it('handles compressed response body', async () => { + // Prepare + const response = new Response('Hello World', { + status: 200, + headers: { + 'content-encoding': 'gzip', + }, + }); + + // Act + const result = await webResponseToProxyResult(response, 'v2'); + + // Assess + expect(result.isBase64Encoded).toBe(true); + expect(result.body).toBe(Buffer.from('Hello World').toString('base64')); }); }); @@ -724,6 +920,214 @@ describe('Converters', () => { expect(result.status).toBe(200); expect(result.text()).resolves.toBe('Hello'); }); + + it('returns Response object as-is when resHeaders is undefined', () => { + // Prepare + const response = new Response('Hello', { + status: 201, + headers: { 'content-type': 'text/plain' }, + }); + + // Act + const result = handlerResultToWebResponse(response); + + // Assess + expect(result).toBe(response); + }); + }); + + describe('webHeadersToApiGatewayHeaders', () => { + it('handles single-value headers', () => { + // Prepare + const headers = new Headers({ + 'content-type': 'application/json', + authorization: 'Bearer token123', + }); + + // Act + const result = webHeadersToApiGatewayHeaders(headers, 'v1'); + + // Assess + expect(result).toEqual({ + headers: { + 'content-type': 'application/json', + authorization: 'Bearer token123', + }, + multiValueHeaders: {}, + }); + }); + + it('handles multi-value headers split by comma', () => { + // Prepare + const headers = new Headers({ + accept: 'application/json, text/html', + 'cache-control': 'no-cache, no-store', + }); + + // Act + const result = webHeadersToApiGatewayHeaders(headers, 'v1'); + + // Assess + expect(result).toEqual({ + headers: {}, + multiValueHeaders: { + accept: ['application/json', 'text/html'], + 'cache-control': ['no-cache', 'no-store'], + }, + }); + }); + + it('handles multi-value headers split by semicolon', () => { + // Prepare + const headers = new Headers({ + 'set-cookie': 'session=abc123; theme=dark', + }); + + // Act + const result = webHeadersToApiGatewayHeaders(headers, 'v1'); + + // Assess + expect(result).toEqual({ + headers: {}, + multiValueHeaders: { + 'set-cookie': ['session=abc123', 'theme=dark'], + }, + }); + }); + + it('handles mixed comma and semicolon delimiters', () => { + // Prepare + const headers = new Headers({ + accept: 'application/json, text/html', + 'set-cookie': 'session=abc; theme=dark', + }); + + // Act + const result = webHeadersToApiGatewayHeaders(headers, 'v1'); + + // Assess + expect(result).toEqual({ + headers: {}, + multiValueHeaders: { + accept: ['application/json', 'text/html'], + 'set-cookie': ['session=abc', 'theme=dark'], + }, + }); + }); + + it('handles duplicate header keys by accumulating values', () => { + // Prepare + const headers = new Headers(); + headers.append('x-custom', 'value1'); + headers.append('x-custom', 'value2'); + + // Act + const result = webHeadersToApiGatewayHeaders(headers, 'v1'); + + // Assess + expect(result).toEqual({ + headers: {}, + multiValueHeaders: { + 'x-custom': ['value1', 'value2'], + }, + }); + }); + + it('moves header from headers to multiValueHeaders when duplicate appears', () => { + // Prepare + const headers = new Headers(); + headers.set('x-custom', 'value1'); + headers.append('x-custom', 'value2'); + + // Act + const result = webHeadersToApiGatewayHeaders(headers, 'v1'); + + // Assess + expect(result).toEqual({ + headers: {}, + multiValueHeaders: { + 'x-custom': ['value1', 'value2'], + }, + }); + }); + + it('handles complex multi-value scenario with existing multiValueHeaders', () => { + // Prepare + const headers = new Headers(); + headers.append('accept', 'application/json'); + headers.append('accept', 'text/html'); + headers.append('accept', 'text/plain'); + + // Act + const result = webHeadersToApiGatewayHeaders(headers, 'v1'); + + // Assess + expect(result).toEqual({ + headers: {}, + multiValueHeaders: { + accept: ['application/json', 'text/html', 'text/plain'], + }, + }); + }); + + it('trims whitespace from start of split values', () => { + // Prepare + const headers = new Headers({ + accept: 'application/json, text/html ,text/plain', + 'set-cookie': 'session=abc; theme=dark ; user=john', + }); + + // Act + const result = webHeadersToApiGatewayHeaders(headers, 'v1'); + + // Assess + expect(result).toEqual({ + headers: {}, + multiValueHeaders: { + accept: ['application/json', 'text/html ', 'text/plain'], + 'set-cookie': ['session=abc', 'theme=dark ', 'user=john'], + }, + }); + }); + + it('handles empty headers', () => { + // Prepare + const headers = new Headers(); + + // Act + const result = webHeadersToApiGatewayHeaders(headers, 'v1'); + + // Assess + expect(result).toEqual({ + headers: {}, + multiValueHeaders: {}, + }); + }); + + it('handles mixed single and multi-value headers', () => { + // Prepare + const headers = new Headers({ + 'content-type': 'application/json', + accept: 'application/json, text/html', + authorization: 'Bearer token123', + 'set-cookie': 'session=abc; theme=dark', + }); + + // Act + const result = webHeadersToApiGatewayHeaders(headers, 'v1'); + + // Assess + expect(result).toEqual({ + headers: { + 'content-type': 'application/json', + authorization: 'Bearer token123', + }, + multiValueHeaders: { + accept: ['application/json', 'text/html'], + 'set-cookie': ['session=abc', 'theme=dark'], + }, + }); + }); }); describe('bodyToNodeStream', () => { diff --git a/packages/event-handler/tests/unit/rest/helpers.ts b/packages/event-handler/tests/unit/rest/helpers.ts index 1a6163e763..470e12ab74 100644 --- a/packages/event-handler/tests/unit/rest/helpers.ts +++ b/packages/event-handler/tests/unit/rest/helpers.ts @@ -1,4 +1,4 @@ -import type { APIGatewayProxyEvent } from 'aws-lambda'; +import type { APIGatewayProxyEvent, APIGatewayProxyEventV2 } from 'aws-lambda'; import { HttpResponseStream } from '../../../src/rest/utils.js'; import type { HandlerResponse, Middleware } from '../../../src/types/rest.js'; @@ -25,6 +25,37 @@ export const createTestEvent = ( resource: '', }); +export const createTestEventV2 = ( + rawPath: string, + method: string, + headers: Record = {} +): APIGatewayProxyEventV2 => ({ + version: '2.0', + routeKey: `${method} ${rawPath}`, + rawPath, + rawQueryString: '', + headers, + requestContext: { + accountId: '123456789012', + apiId: 'api-id', + domainName: 'api.example.com', + domainPrefix: 'api', + http: { + method, + path: rawPath, + protocol: 'HTTP/1.1', + sourceIp: '127.0.0.1', + userAgent: 'test-agent', + }, + requestId: 'test-request-id', + routeKey: `${method} ${rawPath}`, + stage: '$default', + time: '01/Jan/2024:00:00:00 +0000', + timeEpoch: 1704067200000, + }, + isBase64Encoded: false, +}); + export const createTrackingMiddleware = ( name: string, executionOrder: string[] diff --git a/packages/event-handler/tests/unit/rest/utils.test.ts b/packages/event-handler/tests/unit/rest/utils.test.ts index 8b0fbeadad..56176c2782 100644 --- a/packages/event-handler/tests/unit/rest/utils.test.ts +++ b/packages/event-handler/tests/unit/rest/utils.test.ts @@ -6,7 +6,8 @@ import type { import { beforeEach, describe, expect, it, vi } from 'vitest'; import { composeMiddleware, - isAPIGatewayProxyEvent, + isAPIGatewayProxyEventV1, + isAPIGatewayProxyEventV2, isExtendedAPIGatewayProxyResult, } from '../../../src/rest/index.js'; import { @@ -223,7 +224,7 @@ describe('Path Utilities', () => { ); }); - describe('isAPIGatewayProxyEvent', () => { + describe('isAPIGatewayProxyEventV1', () => { const baseValidEvent = { httpMethod: 'GET', path: '/test', @@ -240,7 +241,7 @@ describe('Path Utilities', () => { }; it('should return true for valid API Gateway Proxy event with all fields populated', () => { - expect(isAPIGatewayProxyEvent(baseValidEvent)).toBe(true); + expect(isAPIGatewayProxyEventV1(baseValidEvent)).toBe(true); }); it('should return true for real API Gateway event with null fields', () => { @@ -266,7 +267,7 @@ describe('Path Utilities', () => { isBase64Encoded: false, }; - expect(isAPIGatewayProxyEvent(realEvent)).toBe(true); + expect(isAPIGatewayProxyEventV1(realEvent)).toBe(true); }); it('should return true for event with string body', () => { @@ -276,7 +277,7 @@ describe('Path Utilities', () => { body: '{"key":"value"}', }; - expect(isAPIGatewayProxyEvent(eventWithBody)).toBe(true); + expect(isAPIGatewayProxyEventV1(eventWithBody)).toBe(true); }); it.each([ @@ -293,7 +294,7 @@ describe('Path Utilities', () => { { field: 'stageVariables', value: null }, ])('should return true when $field is $value', ({ field, value }) => { const event = { ...baseValidEvent, [field]: value }; - expect(isAPIGatewayProxyEvent(event)).toBe(true); + expect(isAPIGatewayProxyEventV1(event)).toBe(true); }); it.each([ @@ -319,7 +320,7 @@ describe('Path Utilities', () => { 'should return true when $field contains undefined values', ({ field, value }) => { const event = { ...baseValidEvent, [field]: value }; - expect(isAPIGatewayProxyEvent(event)).toBe(true); + expect(isAPIGatewayProxyEventV1(event)).toBe(true); } ); @@ -330,7 +331,7 @@ describe('Path Utilities', () => { { case: 'number', event: 123 }, { case: 'array', event: [] }, ])('should return false for $case', ({ event }) => { - expect(isAPIGatewayProxyEvent(event)).toBe(false); + expect(isAPIGatewayProxyEventV1(event)).toBe(false); }); it.each([ @@ -369,7 +370,7 @@ describe('Path Utilities', () => { 'should return false when $field is invalid ($value)', ({ field, value }) => { const invalidEvent = { ...baseValidEvent, [field]: value }; - expect(isAPIGatewayProxyEvent(invalidEvent)).toBe(false); + expect(isAPIGatewayProxyEventV1(invalidEvent)).toBe(false); } ); @@ -382,7 +383,173 @@ describe('Path Utilities', () => { ])('should return false when required field %s is missing', (field) => { const incompleteEvent = { ...baseValidEvent }; delete incompleteEvent[field as keyof typeof incompleteEvent]; - expect(isAPIGatewayProxyEvent(incompleteEvent)).toBe(false); + expect(isAPIGatewayProxyEventV1(incompleteEvent)).toBe(false); + }); + }); + + describe('isAPIGatewayProxyEventV2', () => { + const baseValidEvent = { + version: '2.0', + routeKey: 'GET /test', + rawPath: '/test', + rawQueryString: '', + headers: {}, + requestContext: { + accountId: '123456789012', + apiId: 'api-id', + domainName: 'api.example.com', + domainPrefix: 'api', + http: { + method: 'GET', + path: '/test', + protocol: 'HTTP/1.1', + sourceIp: '192.0.2.1', + userAgent: 'agent', + }, + requestId: 'id', + routeKey: 'GET /test', + stage: '$default', + time: '12/Mar/2020:19:03:58 +0000', + timeEpoch: 1583348638390, + }, + isBase64Encoded: false, + }; + + it('should return true for valid API Gateway V2 event with all fields populated', () => { + expect(isAPIGatewayProxyEventV2(baseValidEvent)).toBe(true); + }); + + it('should return true for real API Gateway V2 event with optional fields', () => { + const realEvent = { + version: '2.0', + routeKey: 'POST /users', + rawPath: '/users', + rawQueryString: 'name=john&age=25', + cookies: ['session=abc123', 'theme=dark'], + headers: { + 'content-type': 'application/json', + 'user-agent': 'Mozilla/5.0', + }, + queryStringParameters: { + name: 'john', + age: '25', + }, + pathParameters: { + id: '123', + }, + stageVariables: { + env: 'prod', + }, + body: '{"key":"value"}', + isBase64Encoded: false, + requestContext: { + accountId: '123456789012', + apiId: 'api-id', + domainName: 'api.example.com', + domainPrefix: 'api', + http: { + method: 'POST', + path: '/users', + protocol: 'HTTP/1.1', + sourceIp: '192.0.2.1', + userAgent: 'Mozilla/5.0', + }, + requestId: 'request-id', + routeKey: 'POST /users', + stage: '$default', + time: '12/Mar/2020:19:03:58 +0000', + timeEpoch: 1583348638390, + }, + }; + + expect(isAPIGatewayProxyEventV2(realEvent)).toBe(true); + }); + + it.each([ + { field: 'body', value: undefined }, + { field: 'cookies', value: undefined }, + { field: 'pathParameters', value: undefined }, + { field: 'queryStringParameters', value: undefined }, + { field: 'stageVariables', value: undefined }, + ])( + 'should return true when optional field $field is $value', + ({ field, value }) => { + const event = { ...baseValidEvent, [field]: value }; + expect(isAPIGatewayProxyEventV2(event)).toBe(true); + } + ); + + it.each([ + { case: 'null', event: null }, + { case: 'undefined', event: undefined }, + { case: 'string', event: 'not an object' }, + { case: 'number', event: 123 }, + { case: 'array', event: [] }, + ])('should return false for $case', ({ event }) => { + expect(isAPIGatewayProxyEventV2(event)).toBe(false); + }); + + it.each([ + { field: 'version', value: '1.0' }, + { field: 'version', value: null }, + { field: 'version', value: undefined }, + { field: 'version', value: 123 }, + { field: 'routeKey', value: 123 }, + { field: 'routeKey', value: null }, + { field: 'routeKey', value: undefined }, + { field: 'rawPath', value: 123 }, + { field: 'rawPath', value: null }, + { field: 'rawPath', value: undefined }, + { field: 'rawQueryString', value: 123 }, + { field: 'rawQueryString', value: null }, + { field: 'rawQueryString', value: undefined }, + { field: 'headers', value: 'not an object' }, + { field: 'headers', value: null }, + { field: 'headers', value: undefined }, + { field: 'headers', value: 123 }, + { field: 'cookies', value: 'not an array' }, + { field: 'cookies', value: null }, + { field: 'cookies', value: 123 }, + { field: 'queryStringParameters', value: 'not an object' }, + { field: 'queryStringParameters', value: null }, + { field: 'queryStringParameters', value: 123 }, + { field: 'pathParameters', value: 'not an object' }, + { field: 'pathParameters', value: null }, + { field: 'pathParameters', value: 123 }, + { field: 'stageVariables', value: 'not an object' }, + { field: 'stageVariables', value: null }, + { field: 'stageVariables', value: 123 }, + { field: 'body', value: null }, + { field: 'requestContext', value: 'not an object' }, + { field: 'requestContext', value: null }, + { field: 'requestContext', value: undefined }, + { field: 'requestContext', value: 123 }, + { field: 'isBase64Encoded', value: 'not a boolean' }, + { field: 'isBase64Encoded', value: null }, + { field: 'isBase64Encoded', value: undefined }, + { field: 'isBase64Encoded', value: 123 }, + { field: 'body', value: 123 }, + { field: 'body', value: {} }, + ])( + 'should return false when $field is invalid ($value)', + ({ field, value }) => { + const invalidEvent = { ...baseValidEvent, [field]: value }; + expect(isAPIGatewayProxyEventV2(invalidEvent)).toBe(false); + } + ); + + it.each([ + 'version', + 'routeKey', + 'rawPath', + 'rawQueryString', + 'headers', + 'requestContext', + 'isBase64Encoded', + ])('should return false when required field %s is missing', (field) => { + const incompleteEvent = { ...baseValidEvent }; + delete incompleteEvent[field as keyof typeof incompleteEvent]; + expect(isAPIGatewayProxyEventV2(incompleteEvent)).toBe(false); }); });