diff --git a/packages/event-handler/src/rest/Router.ts b/packages/event-handler/src/rest/Router.ts index 512ed2189b..40699f804f 100644 --- a/packages/event-handler/src/rest/Router.ts +++ b/packages/event-handler/src/rest/Router.ts @@ -11,6 +11,8 @@ import { isDevMode, } from '@aws-lambda-powertools/commons/utils/env'; import type { + ALBEvent, + ALBResult, APIGatewayProxyEvent, APIGatewayProxyEventV2, APIGatewayProxyResult, @@ -28,10 +30,10 @@ import type { RequestContext, ResolveStreamOptions, ResponseStream, - ResponseType, RestRouteOptions, RestRouterOptions, RouteHandler, + RouterResponse, } from '../types/rest.js'; import { HttpStatusCodes, HttpVerbs } from './constants.js'; import { @@ -54,8 +56,10 @@ import { composeMiddleware, getBase64EncodingFromHeaders, getBase64EncodingFromResult, + getResponseType, getStatusCode, HttpResponseStream, + isALBEvent, isAPIGatewayProxyEventV1, isAPIGatewayProxyEventV2, isBinaryResult, @@ -219,16 +223,18 @@ class Router { context: Context, options?: ResolveOptions ): Promise { - if (!isAPIGatewayProxyEventV1(event) && !isAPIGatewayProxyEventV2(event)) { + if ( + !isAPIGatewayProxyEventV1(event) && + !isAPIGatewayProxyEventV2(event) && + !isALBEvent(event) + ) { this.logger.error( 'Received an event that is not compatible with this resolver' ); throw new InvalidEventError(); } - const responseType: ResponseType = isAPIGatewayProxyEventV2(event) - ? 'ApiGatewayV2' - : 'ApiGatewayV1'; + const responseType = getResponseType(event); let req: Request; try { @@ -357,16 +363,21 @@ class Router { context: Context, options?: ResolveOptions ): Promise; + public async resolve( + event: ALBEvent, + context: Context, + options?: ResolveOptions + ): Promise; public async resolve( event: unknown, context: Context, options?: ResolveOptions - ): Promise; + ): Promise; public async resolve( event: unknown, context: Context, options?: ResolveOptions - ): Promise { + ): Promise { const reqCtx = await this.#resolve(event, context, options); const isBase64Encoded = reqCtx.isBase64Encoded ?? diff --git a/packages/event-handler/src/rest/constants.ts b/packages/event-handler/src/rest/constants.ts index 5ec0377e6d..87f5cf7da6 100644 --- a/packages/event-handler/src/rest/constants.ts +++ b/packages/event-handler/src/rest/constants.ts @@ -116,9 +116,78 @@ const COMPRESSION_ENCODING_TYPES = { ANY: '*', } as const; +const HttpStatusText: Record = { + // 2xx Success + 200: 'OK', + 201: 'Created', + 202: 'Accepted', + 203: 'Non-Authoritative Information', + 204: 'No Content', + 205: 'Reset Content', + 206: 'Partial Content', + 207: 'Multi-Status', + 208: 'Already Reported', + 226: 'IM Used', + + // 3xx Redirection + 300: 'Multiple Choices', + 301: 'Moved Permanently', + 302: 'Found', + 303: 'See Other', + 304: 'Not Modified', + 305: 'Use Proxy', + 307: 'Temporary Redirect', + 308: 'Permanent Redirect', + + // 4xx Client Error + 400: 'Bad Request', + 401: 'Unauthorized', + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 406: 'Not Acceptable', + 407: 'Proxy Authentication Required', + 408: 'Request Timeout', + 409: 'Conflict', + 410: 'Gone', + 411: 'Length Required', + 412: 'Precondition Failed', + 413: 'Request Entity Too Large', + 414: 'Request-URI Too Long', + 415: 'Unsupported Media Type', + 416: 'Requested Range Not Satisfiable', + 417: 'Expectation Failed', + 418: "I'm a Teapot", + 421: 'Misdirected Request', + 422: 'Unprocessable Entity', + 423: 'Locked', + 424: 'Failed Dependency', + 425: 'Too Early', + 426: 'Upgrade Required', + 428: 'Precondition Required', + 429: 'Too Many Requests', + 431: 'Request Header Fields Too Large', + 451: 'Unavailable For Legal Reasons', + + // 5xx Server Error + 500: 'Internal Server Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + 505: 'HTTP Version Not Supported', + 506: 'Variant Also Negotiates', + 507: 'Insufficient Storage', + 508: 'Loop Detected', + 510: 'Not Extended', + 511: 'Network Authentication Required', +}; + export { HttpVerbs, HttpStatusCodes, + HttpStatusText, PARAM_PATTERN, SAFE_CHARS, UNSAFE_CHARS, diff --git a/packages/event-handler/src/rest/converters.ts b/packages/event-handler/src/rest/converters.ts index 62447b7195..6cf4d7f210 100644 --- a/packages/event-handler/src/rest/converters.ts +++ b/packages/event-handler/src/rest/converters.ts @@ -1,6 +1,8 @@ import { Readable } from 'node:stream'; import type streamWeb from 'node:stream/web'; import type { + ALBEvent, + ALBResult, APIGatewayProxyEvent, APIGatewayProxyEventV2, APIGatewayProxyResult, @@ -16,9 +18,10 @@ import type { V1Headers, WebResponseToProxyResultOptions, } from '../types/rest.js'; -import { HttpStatusCodes } from './constants.js'; +import { HttpStatusCodes, HttpStatusText, HttpVerbs } from './constants.js'; import { InvalidHttpMethodError } from './errors.js'; import { + isALBEvent, isAPIGatewayProxyEventV2, isBinaryResult, isExtendedAPIGatewayProxyResult, @@ -47,11 +50,11 @@ const createBody = (body: string | null, isBase64Encoded: boolean) => { * Populates headers from single and multi-value header entries. * * @param headers - The Headers object to populate - * @param event - The API Gateway proxy event + * @param event - The API Gateway proxy event or ALB event */ const populateV1Headers = ( headers: Headers, - event: APIGatewayProxyEvent + event: APIGatewayProxyEvent | ALBEvent ): void => { for (const [name, value] of Object.entries(event.headers ?? {})) { if (value !== undefined) headers.set(name, value); @@ -71,9 +74,12 @@ const populateV1Headers = ( * Populates URL search parameters from single and multi-value query string parameters. * * @param url - The URL object to populate - * @param event - The API Gateway proxy event + * @param event - The API Gateway proxy event or ALB event */ -const populateV1QueryParams = (url: URL, event: APIGatewayProxyEvent): void => { +const populateV1QueryParams = ( + url: URL, + event: APIGatewayProxyEvent | ALBEvent +): void => { for (const [name, value] of Object.entries( event.queryStringParameters ?? {} )) { @@ -154,14 +160,45 @@ const proxyEventV2ToWebRequest = (event: APIGatewayProxyEventV2): Request => { }; /** - * Converts an API Gateway proxy event (V1 or V2) to a Web API Request object. + * Converts an ALB event to a Web API Request object. + * + * @param event - The ALB event + * @returns A Web API Request object + */ +const albEventToWebRequest = (event: ALBEvent): Request => { + const { httpMethod, path } = event; + + const headers = new Headers(); + populateV1Headers(headers, event); + + const hostname = headers.get('Host') ?? 'localhost'; + const protocol = headers.get('X-Forwarded-Proto') ?? 'https'; + + const url = new URL(path, `${protocol}://${hostname}/`); + populateV1QueryParams(url, event); + + // ALB events represent GET and PATCH request bodies as empty strings + const body = + httpMethod === HttpVerbs.GET || httpMethod === HttpVerbs.PATCH + ? null + : createBody(event.body ?? null, event.isBase64Encoded); + + return new Request(url.toString(), { + method: httpMethod, + headers, + body: body, + }); +}; + +/** + * Converts an API Gateway proxy event (V1 or V2) or ALB event 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) + * @param event - The API Gateway proxy event (V1 or V2) or ALB event * @returns A Web API Request object */ const proxyEventToWebRequest = ( - event: APIGatewayProxyEvent | APIGatewayProxyEventV2 + event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBEvent ): Request => { if (isAPIGatewayProxyEventV2(event)) { const method = event.requestContext.http.method.toUpperCase(); @@ -170,10 +207,13 @@ const proxyEventToWebRequest = ( } return proxyEventV2ToWebRequest(event); } - const method = event.requestContext.httpMethod.toUpperCase(); + const method = event.httpMethod.toUpperCase(); if (!isHttpMethod(method)) { throw new InvalidHttpMethodError(method); } + if (isALBEvent(event)) { + return albEventToWebRequest(event); + } return proxyEventV1ToWebRequest(event); }; @@ -319,6 +359,42 @@ const webResponseToProxyResultV2 = async ( return result; }; +/** + * Converts a Web API Response object to an ALB result. + * + * @param response - The Web API Response object + * @param isBase64Encoded - Whether the response body should be base64 encoded (e.g., for binary or compressed content) + * @returns An ALB result + */ +const webResponseToALBResult = async ( + response: Response, + isBase64Encoded?: boolean +): Promise => { + const { headers, multiValueHeaders } = webHeadersToApiGatewayV1Headers( + response.headers + ); + + const body = isBase64Encoded + ? await responseBodyToBase64(response) + : await response.text(); + + const statusText = response.statusText || HttpStatusText[response.status]; + + const result: ALBResult = { + statusCode: response.status, + statusDescription: `${response.status} ${statusText}`, + headers, + body, + isBase64Encoded, + }; + + if (Object.keys(multiValueHeaders).length > 0) { + result.multiValueHeaders = multiValueHeaders; + } + + return result; +}; + const webResponseToProxyResult = ( response: Response, responseType: T, @@ -330,6 +406,11 @@ const webResponseToProxyResult = ( ResponseTypeMap[T] >; } + if (responseType === 'ALB') { + return webResponseToALBResult(response, isBase64Encoded) as Promise< + ResponseTypeMap[T] + >; + } return webResponseToProxyResultV2(response, isBase64Encoded) as Promise< ResponseTypeMap[T] >; diff --git a/packages/event-handler/src/rest/index.ts b/packages/event-handler/src/rest/index.ts index 7d9d8cabc1..f2ee9d1664 100644 --- a/packages/event-handler/src/rest/index.ts +++ b/packages/event-handler/src/rest/index.ts @@ -21,6 +21,7 @@ export { export { Router } from './Router.js'; export { composeMiddleware, + isALBEvent, isAPIGatewayProxyEventV1, isAPIGatewayProxyEventV2, isExtendedAPIGatewayProxyResult, diff --git a/packages/event-handler/src/rest/utils.ts b/packages/event-handler/src/rest/utils.ts index d856126342..780c61a065 100644 --- a/packages/event-handler/src/rest/utils.ts +++ b/packages/event-handler/src/rest/utils.ts @@ -5,6 +5,7 @@ import { isString, } from '@aws-lambda-powertools/commons/typeutils'; import type { + ALBEvent, APIGatewayProxyEvent, APIGatewayProxyEventV2, StreamifyHandler, @@ -21,6 +22,7 @@ import type { Middleware, Path, ResponseStream, + ResponseType, ValidationResult, } from '../types/rest.js'; import { @@ -44,7 +46,7 @@ export function compilePath(path: Path): CompiledRoute { PARAM_PATTERN, (_match, paramName) => { paramNames.push(paramName); - return `(?<${paramName}>[${SAFE_CHARS}${UNSAFE_CHARS}\\w]+)`; + return String.raw`(?<${paramName}>[${SAFE_CHARS}${UNSAFE_CHARS}\w]+)`; } ); @@ -140,6 +142,25 @@ export const isAPIGatewayProxyEventV2 = ( ); }; +/** + * Type guard to check if the provided event is an ALB event. + * + * @param event - The incoming event to check + */ +export const isALBEvent = (event: unknown): event is ALBEvent => { + if (!isRecord(event)) return false; + if (!isRecord(event.requestContext)) return false; + return isRecord(event.requestContext.elb); +}; + +export const getResponseType = ( + event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBEvent +): ResponseType => { + if (isAPIGatewayProxyEventV2(event)) return 'ApiGatewayV2'; + if (isALBEvent(event)) return 'ALB'; + return 'ApiGatewayV1'; +}; + 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 fb878cd454..125cedb01e 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -4,6 +4,8 @@ import type { JSONValue, } from '@aws-lambda-powertools/commons/types'; import type { + ALBEvent, + ALBResult, APIGatewayProxyEvent, APIGatewayProxyEventV2, APIGatewayProxyResult, @@ -14,16 +16,17 @@ import type { HttpStatusCodes, HttpVerbs } from '../rest/constants.js'; import type { Route } from '../rest/Route.js'; import type { ResolveOptions } from './common.js'; -type ResponseType = 'ApiGatewayV1' | 'ApiGatewayV2'; +type ResponseType = 'ApiGatewayV1' | 'ApiGatewayV2' | 'ALB'; type ResponseTypeMap = { ApiGatewayV1: APIGatewayProxyResult; ApiGatewayV2: APIGatewayProxyStructuredResultV2; + ALB: ALBResult; }; type RequestContext = { req: Request; - event: APIGatewayProxyEvent | APIGatewayProxyEventV2; + event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBEvent; context: Context; res: Response; params: Record; @@ -76,6 +79,7 @@ type ExtendedAPIGatewayProxyResultBody = BinaryResult | string; type ExtendedAPIGatewayProxyResult = Omit & { body: ExtendedAPIGatewayProxyResultBody; cookies?: string[]; + statusDescription?: string; }; type HandlerResponse = @@ -242,6 +246,11 @@ type WebResponseToProxyResultOptions = { isBase64Encoded?: boolean; }; +type RouterResponse = + | APIGatewayProxyResult + | APIGatewayProxyStructuredResultV2 + | ALBResult; + export type { BinaryResult, ExtendedAPIGatewayProxyResult, @@ -268,6 +277,7 @@ export type { RestRouteOptions, RestRouteHandlerOptions, RouteRegistryOptions, + RouterResponse, ValidationResult, CompressionOptions, NextFunction, 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 17cd96906e..d27b77741a 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,6 +1,7 @@ import { Readable } from 'node:stream'; import context from '@aws-lambda-powertools/testing-utils/context'; import { describe, expect, it, vi } from 'vitest'; +import { HttpStatusText } from '../../../../src/rest/constants.js'; import { InvalidEventError } from '../../../../src/rest/errors.js'; import { HttpStatusCodes, @@ -8,11 +9,16 @@ import { Router, } from '../../../../src/rest/index.js'; import type { HttpMethod, RouteHandler } from '../../../../src/types/rest.js'; -import { createTestEvent, createTestEventV2 } from '../helpers.js'; +import { + createTestALBEvent, + createTestEvent, + createTestEventV2, +} from '../helpers.js'; describe.each([ { version: 'V1', createEvent: createTestEvent }, { version: 'V2', createEvent: createTestEventV2 }, + { version: 'ALB', createEvent: createTestALBEvent }, ])('Class: Router - Basic Routing ($version)', ({ createEvent }) => { const httpMethods = [ ['GET', 'get'], @@ -312,9 +318,110 @@ describe('Class: Router - V2 Cookies Support', () => { }); }); +describe('Class: Router - ALB Support', () => { + it('handles ALB event with statusDescription', async () => { + // Prepare + const app = new Router(); + app.get('/test', () => ({ message: 'success' })); + + // Act + const result = await app.resolve( + createTestALBEvent('/test', 'GET'), + context + ); + + // Assess + expect(result).toEqual({ + statusCode: 200, + statusDescription: '200 OK', + body: JSON.stringify({ message: 'success' }), + headers: { 'content-type': 'application/json' }, + isBase64Encoded: false, + }); + }); + + it('handles ALB event with multiValueHeaders', 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( + createTestALBEvent('/test', 'GET'), + context + ); + + // Assess + expect(result).toEqual({ + statusCode: 200, + statusDescription: '200 OK', + body: JSON.stringify({ message: 'success' }), + headers: { 'content-type': 'application/json' }, + multiValueHeaders: { 'set-cookie': ['session=abc123', 'theme=dark'] }, + isBase64Encoded: false, + }); + }); + + it('handles ALB POST request with body', async () => { + // Prepare + const app = new Router(); + app.post('/test', async ({ req }) => { + const body = await req.json(); + return { received: body }; + }); + + // Act + const result = await app.resolve( + createTestALBEvent('/test', 'POST', {}, { data: 'test' }), + context + ); + + // Assess + expect(result.statusCode).toBe(200); + expect(result.body).toBe(JSON.stringify({ received: { data: 'test' } })); + }); + + it.each( + Object.entries(HttpStatusText).map(([code, text]) => ({ + statusCode: Number(code), + expectedDescription: `${code} ${text}`, + })) + )( + 'returns statusDescription "$expectedDescription" for status code $statusCode', + async ({ statusCode, expectedDescription }) => { + // Prepare + const app = new Router(); + const noBodyStatuses = new Set([201, 204, 205, 304]); + const body = noBodyStatuses.has(statusCode) + ? null + : JSON.stringify({ status: statusCode }); + app.get('/test', () => new Response(body, { status: statusCode })); + + // Act + const result = await app.resolve( + createTestALBEvent('/test', 'GET'), + context + ); + + // Assess + expect(result.statusCode).toBe(statusCode); + expect(result).toHaveProperty('statusDescription', expectedDescription); + if (!noBodyStatuses.has(statusCode)) { + expect(result.body).toBe(JSON.stringify({ status: statusCode })); + } + } + ); +}); + describe.each([ { version: 'V1', createEvent: createTestEvent }, { version: 'V2', createEvent: createTestEventV2 }, + { version: 'ALB', createEvent: createTestALBEvent }, ])('Class: Router - Binary Result ($version)', ({ createEvent }) => { it('handles ArrayBuffer as direct return type', async () => { // Prepare @@ -385,12 +492,10 @@ describe.each([ const result = await app.resolve(createEvent('/media', 'GET'), context); // Assess - expect(result).toEqual({ - statusCode: 200, - body: Buffer.from('binary data').toString('base64'), - headers: { 'content-type': contentType }, - isBase64Encoded: true, - }); + expect(result.statusCode).toBe(200); + expect(result.body).toBe(Buffer.from('binary data').toString('base64')); + expect(result.headers?.['content-type']).toBe(contentType); + expect(result.isBase64Encoded).toBe(true); } ); @@ -409,11 +514,9 @@ describe.each([ const result = await app.resolve(createEvent('/text', 'GET'), context); // Assess - expect(result).toEqual({ - statusCode: 200, - body: 'text data', - headers: { 'content-type': 'text/plain' }, - isBase64Encoded: false, - }); + expect(result.statusCode).toBe(200); + expect(result.body).toBe('text data'); + expect(result.headers?.['content-type']).toBe('text/plain'); + expect(result.isBase64Encoded).toBe(false); }); }); diff --git a/packages/event-handler/tests/unit/rest/converters.test.ts b/packages/event-handler/tests/unit/rest/converters.test.ts index 60f698ec59..1cca172953 100644 --- a/packages/event-handler/tests/unit/rest/converters.test.ts +++ b/packages/event-handler/tests/unit/rest/converters.test.ts @@ -10,7 +10,11 @@ import { proxyEventToWebRequest, webResponseToProxyResult, } from '../../../src/rest/index.js'; -import { createTestEvent, createTestEventV2 } from './helpers.js'; +import { + createTestALBEvent, + createTestEvent, + createTestEventV2, +} from './helpers.js'; describe('Converters', () => { describe('proxyEventToWebRequest (V1)', () => { @@ -404,6 +408,146 @@ describe('Converters', () => { }); }); + describe('proxyEventToWebRequest (ALB)', () => { + const baseEvent = createTestALBEvent('/test', 'GET'); + it('converts basic GET request', () => { + // Prepare & Act + const request = proxyEventToWebRequest(baseEvent); + + // Assess + expect(request).toBeInstanceOf(Request); + expect(request.method).toBe('GET'); + expect(request.url).toBe('https://localhost/test'); + expect(request.body).toBe(null); + }); + + it('uses Host header over default', () => { + // Prepare + const event = { + ...baseEvent, + 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 = createTestALBEvent('/test', 'GET', { + 'X-Forwarded-Proto': 'http', + }); + + // Act + const request = proxyEventToWebRequest(event); + + // Assess + expect(request).toBeInstanceOf(Request); + expect(request.url).toBe('http://localhost/test'); + }); + + it('handles POST request with string body', () => { + // Prepare + const event = { + ...baseEvent, + httpMethod: '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 = { + ...baseEvent, + httpMethod: 'POST', + body: base64Text, + isBase64Encoded: true, + }; + + // Act + const request = proxyEventToWebRequest(event); + + // Assess + expect(request).toBeInstanceOf(Request); + expect(request.text()).resolves.toBe(originalText); + }); + + it('handles multiValueHeaders', () => { + // Prepare + const event = { + ...baseEvent, + multiValueHeaders: { + Accept: ['application/json', 'text/html'], + 'Custom-Header': ['value1', 'value2'], + }, + }; + + // Act + const request = proxyEventToWebRequest(event); + + // Assess + expect(request).toBeInstanceOf(Request); + expect(request.headers.get('Accept')).toBe('application/json, text/html'); + expect(request.headers.get('Custom-Header')).toBe('value1, value2'); + }); + + it('handles queryStringParameters', () => { + // Prepare + const event = { + ...baseEvent, + queryStringParameters: { + name: 'john', + age: '25', + }, + }; + + // Act + const request = proxyEventToWebRequest(event); + + // Assess + expect(request).toBeInstanceOf(Request); + const url = new URL(request.url); + expect(url.searchParams.get('name')).toBe('john'); + expect(url.searchParams.get('age')).toBe('25'); + }); + + it('handles multiValueQueryStringParameters', () => { + // Prepare + const event = { + ...baseEvent, + multiValueQueryStringParameters: { + filter: ['name', 'age'], + sort: ['desc'], + }, + }; + + // Act + const request = proxyEventToWebRequest(event); + + // Assess + expect(request).toBeInstanceOf(Request); + const url = new URL(request.url); + expect(url.searchParams.getAll('filter')).toEqual(['name', 'age']); + expect(url.searchParams.get('sort')).toBe('desc'); + }); + }); + describe('proxyEventToWebRequest (V2)', () => { it('converts basic GET request', () => { // Prepare @@ -711,6 +855,67 @@ describe('Converters', () => { }); }); + describe('webResponseToProxyResult - ALB', () => { + it('converts basic Response to ALB result', async () => { + // Prepare + const response = new Response('Hello World', { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + // Act + const result = await webResponseToProxyResult(response, 'ALB'); + + // Assess + expect(result.statusCode).toBe(200); + expect(result.statusDescription).toBe('200 OK'); + expect(result.body).toBe('Hello World'); + expect(result.isBase64Encoded).toBe(false); + expect(result.headers).toEqual({ 'content-type': 'application/json' }); + }); + + it('handles multi-value headers', async () => { + // Prepare + const response = new Response('Hello', { + status: 200, + headers: { + 'Set-Cookie': 'cookie1=value1; cookie2=value2', + 'Content-type': 'application/json', + }, + }); + + // Act + const result = await webResponseToProxyResult(response, 'ALB'); + + // Assess + expect(result.headers).toEqual({ 'content-type': 'application/json' }); + expect(result.multiValueHeaders).toEqual({ + 'set-cookie': ['cookie1=value1', 'cookie2=value2'], + }); + }); + + it('respects isBase64Encoded option', async () => { + // Prepare + const response = new Response('Hello World', { + status: 200, + headers: { + 'content-encoding': 'gzip', + }, + }); + + // Act + const result = await webResponseToProxyResult(response, 'ALB', { + isBase64Encoded: true, + }); + + // Assess + expect(result.isBase64Encoded).toBe(true); + expect(result.body).toBe(Buffer.from('Hello World').toString('base64')); + }); + }); + describe('webResponseToProxyResult - V2', () => { it('converts basic Response to API Gateway V2 result', async () => { // Prepare diff --git a/packages/event-handler/tests/unit/rest/helpers.ts b/packages/event-handler/tests/unit/rest/helpers.ts index d5ac04da5f..ec19eaad50 100644 --- a/packages/event-handler/tests/unit/rest/helpers.ts +++ b/packages/event-handler/tests/unit/rest/helpers.ts @@ -1,16 +1,21 @@ import { Writable } from 'node:stream'; +import type { JSONValue } from '@aws-lambda-powertools/commons/types'; import type { + ALBEvent, + ALBResult, APIGatewayProxyEvent, APIGatewayProxyEventV2, APIGatewayProxyResult, APIGatewayProxyStructuredResultV2, Context, } from 'aws-lambda'; +import { HttpVerbs } from '../../../src/rest/constants.js'; import type { Router } from '../../../src/rest/Router.js'; import type { HandlerResponse, ResponseStream as IResponseStream, Middleware, + RouterResponse, } from '../../../src/types/rest.js'; export const createTestEvent = ( @@ -36,6 +41,17 @@ export const createTestEvent = ( resource: '', }); +const createAlbBody = (httpMethod: string, body: JSONValue): string | null => { + // ALB events represent GET and PATCH request bodies as empty strings + if (httpMethod === HttpVerbs.GET || httpMethod === HttpVerbs.PATCH) { + return ''; + } + if (body === null) { + return null; + } + return JSON.stringify(body); +}; + export const createTestEventV2 = ( rawPath: string, method: string, @@ -67,6 +83,28 @@ export const createTestEventV2 = ( isBase64Encoded: false, }); +export const createTestALBEvent = ( + path: string, + httpMethod: string, + headers: Record = {}, + body: JSONValue = null +): ALBEvent => ({ + path, + httpMethod, + headers, + body: createAlbBody(httpMethod, body), + multiValueHeaders: {}, + isBase64Encoded: false, + queryStringParameters: undefined, + multiValueQueryStringParameters: undefined, + requestContext: { + elb: { + targetGroupArn: + 'arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/test/50dc6c495c0c9188', + }, + }, +}); + export const createTrackingMiddleware = ( name: string, executionOrder: string[] @@ -173,10 +211,8 @@ export const createHandler = (app: Router) => { event: APIGatewayProxyEventV2, context: Context ): Promise; - function handler( - event: unknown, - context: Context - ): Promise; + function handler(event: ALBEvent, context: Context): Promise; + function handler(event: unknown, context: Context): Promise; function handler(event: unknown, context: Context) { return app.resolve(event, context); } @@ -193,10 +229,8 @@ export const createHandlerWithScope = (app: Router, scope: unknown) => { event: APIGatewayProxyEventV2, context: Context ): Promise; - function handler( - event: unknown, - context: Context - ): Promise; + function handler(event: ALBEvent, context: Context): Promise; + function handler(event: unknown, context: Context): Promise; function handler(event: unknown, context: Context) { return app.resolve(event, context, { scope }); } diff --git a/packages/event-handler/tests/unit/rest/utils.test.ts b/packages/event-handler/tests/unit/rest/utils.test.ts index 0beff14dce..717a92cf42 100644 --- a/packages/event-handler/tests/unit/rest/utils.test.ts +++ b/packages/event-handler/tests/unit/rest/utils.test.ts @@ -6,6 +6,7 @@ import type { import { beforeEach, describe, expect, it, vi } from 'vitest'; import { composeMiddleware, + isALBEvent, isAPIGatewayProxyEventV1, isAPIGatewayProxyEventV2, isExtendedAPIGatewayProxyResult, @@ -553,6 +554,83 @@ describe('Path Utilities', () => { }); }); + describe('isALBEvent', () => { + const baseValidEvent = { + httpMethod: 'GET', + path: '/test', + headers: {}, + requestContext: { + elb: { + targetGroupArn: + 'arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/my-target-group/50dc6c495c0c9188', + }, + }, + isBase64Encoded: false, + body: null, + }; + + it('should return true for valid ALB event', () => { + expect(isALBEvent(baseValidEvent)).toBe(true); + }); + + it('should return true for ALB event with body', () => { + const eventWithBody = { + ...baseValidEvent, + body: '{"key":"value"}', + }; + + expect(isALBEvent(eventWithBody)).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(isALBEvent(event)).toBe(false); + }); + + it.each([ + { field: 'requestContext', value: null }, + { field: 'requestContext', value: undefined }, + { field: 'requestContext', value: 'not an object' }, + { field: 'requestContext', value: 123 }, + ])( + 'should return false when $field is invalid ($value)', + ({ field, value }) => { + const invalidEvent = { ...baseValidEvent, [field]: value }; + expect(isALBEvent(invalidEvent)).toBe(false); + } + ); + + it('should return false when requestContext.elb is missing', () => { + const eventWithoutElb = { + ...baseValidEvent, + requestContext: {}, + }; + + expect(isALBEvent(eventWithoutElb)).toBe(false); + }); + + it.each([ + { value: null }, + { value: undefined }, + { value: 'not an object' }, + { value: 123 }, + ])( + 'should return false when requestContext.elb is invalid ($value)', + ({ value }) => { + const invalidEvent = { + ...baseValidEvent, + requestContext: { elb: value }, + }; + expect(isALBEvent(invalidEvent)).toBe(false); + } + ); + }); + describe('isAPIGatewayProxyResult', () => { it('should return true for valid API Gateway Proxy result', () => { const validResult: APIGatewayProxyResult = {