diff --git a/docs/features/event-handler/http.md b/docs/features/event-handler/http.md index d0c8f0b5bf..15de815aa7 100644 --- a/docs/features/event-handler/http.md +++ b/docs/features/event-handler/http.md @@ -312,6 +312,10 @@ You can combine both request and response validation in a single route by provid You can access request details such as headers, query parameters, and body using the `Request` object provided to your route handlers and middleware functions via `reqCtx.req`. +For API Gateway v1, API Gateway v2, and ALB events, the router automatically decodes base64 request bodies into bytes. Use `reqCtx.req.arrayBuffer()` to read binary uploads or `reqCtx.req.formData()` to read multipart uploads. You can continue using `reqCtx.req.text()` and `reqCtx.req.json()` for text and JSON bodies. + +The router preserves the request's `Content-Type` header. If the event contains a base64 body without this header, the router leaves the content type unset. Plain string bodies without a content type retain the Web `Request` default of `text/plain;charset=UTF-8`. Bodies on GET and HEAD requests are ignored. + ### Error handling You can use the `errorHandler()` method as a higher-order function or class method decorator to define a custom error handler for errors thrown in your route handlers or middleware. diff --git a/packages/event-handler/src/http/Router.ts b/packages/event-handler/src/http/Router.ts index 05511df068..201a1d6783 100644 --- a/packages/event-handler/src/http/Router.ts +++ b/packages/event-handler/src/http/Router.ts @@ -22,6 +22,7 @@ import type { import type { IStore } from '../store/Store.js'; import { Store } from '../store/Store.js'; import type { + ClassifiedEvent, Env, ErrorConstructor, ErrorHandler, @@ -53,14 +54,14 @@ import type { import type { HandlerResponse, ResolveOptions } from '../types/index.js'; import { HttpStatusCodes, HttpVerbs } from './constants.js'; import { - proxyEventToWebRequest, + classifiedEventToWebRequest, + classifyEvent, webHeadersToApiGatewayHeaders, webResponseToProxyResult, } from './converters.js'; import { ErrorHandlerRegistry } from './ErrorHandlerRegistry.js'; import { HttpError, - InvalidEventError, InvalidHttpMethodError, MethodNotAllowedError, NotFoundError, @@ -73,15 +74,22 @@ import { composeMiddleware, getBase64EncodingFromHeaders, HttpResponseStream, - isALBEvent, - isAPIGatewayProxyEventV1, - isAPIGatewayProxyEventV2, isBinaryResult, isExtendedAPIGatewayProxyResult, resolvePrefixedPath, stripTrailingSlashes, } from './utils.js'; +/** + * Carries the response and metadata needed for buffered or streaming output. + * + * @internal + */ +type ResolvedResponse = Pick< + RequestContext, + 'res' | 'responseType' | 'isBase64Encoded' +>; + class Router { /** * @deprecated This property is deprecated and will be removed in a future major version, please use `requestContext.shared` instead. @@ -245,8 +253,15 @@ class Router { }; } + /** + * Builds the middleware context from a classified event and its Web Request. + * + * @param classified - The event and its integration + * @param context - The Lambda context + * @param options - The request, response, and store accessors + */ #buildRequestContext( - event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBEvent, + classified: ClassifiedEvent, context: Context, options: { req: Request; @@ -254,7 +269,8 @@ class Router { isHttpStreaming?: boolean; } & Pick, 'set' | 'get' | 'has' | 'delete' | 'shared'> ): RequestContext { - const common = { + return { + ...classified, context, req: options.req, res: options.res, @@ -267,14 +283,6 @@ class Router { delete: options.delete, shared: options.shared, }; - - if (isAPIGatewayProxyEventV2(event)) { - return { ...common, event, responseType: 'ApiGatewayV2' }; - } - if (isALBEvent(event)) { - return { ...common, event, responseType: 'ALB' }; - } - return { ...common, event, responseType: 'ApiGatewayV1' }; } /** @@ -284,50 +292,44 @@ class Router { * @param event - The Lambda event to resolve * @param context - The Lambda context * @param options - Optional resolve options for scope binding - * @returns A handler response (Response, JSONObject, or ExtendedAPIGatewayProxyResult) */ async #resolve( event: unknown, context: Context, options?: HttpResolveOptions - ): Promise> { - if ( - !isAPIGatewayProxyEventV1(event) && - !isAPIGatewayProxyEventV2(event) && - !isALBEvent(event) - ) { + ): Promise { + let classified: ClassifiedEvent; + try { + classified = classifyEvent(event); + } catch (error) { this.logger.error( 'Received an event that is not compatible with this resolver' ); - throw new InvalidEventError(); + throw error; } - const requestStore = new Store>(); - const storeAccessors = this.#createStoreAccessors(requestStore); - let req: Request; try { - req = proxyEventToWebRequest(event); + req = classifiedEventToWebRequest(classified); } 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 this.#buildRequestContext(event, context, { - req: new Request('https://invalid'), + return { + responseType: classified.responseType, res: new Response(null, { status: HttpStatusCodes.METHOD_NOT_ALLOWED, ...(options?.isHttpStreaming && { headers: { 'transfer-encoding': 'chunked' }, }), }), - ...storeAccessors, - }); + }; } throw err; } - const requestContext = this.#buildRequestContext(event, context, { + const requestStore = new Store>(); + const storeAccessors = this.#createStoreAccessors(requestStore); + const requestContext = this.#buildRequestContext(classified, context, { req, res: new Response('', { status: HttpStatusCodes.INTERNAL_SERVER_ERROR, @@ -441,13 +443,15 @@ class Router { context: Context, options?: ResolveOptions ): Promise { - const reqCtx = await this.#resolve(event, context, options); + const resolvedResponse = await this.#resolve(event, context, options); const isBase64Encoded = - reqCtx.isBase64Encoded ?? - getBase64EncodingFromHeaders(reqCtx.res.headers); - return webResponseToProxyResult(reqCtx.res, reqCtx.responseType, { - isBase64Encoded, - }); + resolvedResponse.isBase64Encoded ?? + getBase64EncodingFromHeaders(resolvedResponse.res.headers); + return webResponseToProxyResult( + resolvedResponse.res, + resolvedResponse.responseType, + { isBase64Encoded } + ); } /** @@ -464,36 +468,36 @@ class Router { context: Context, options: ResolveStreamOptions ): Promise { - const reqCtx = await this.#resolve(event, context, { + const resolvedResponse = await this.#resolve(event, context, { ...options, isHttpStreaming: true, }); - await this.#streamHandlerResponse(reqCtx, options.responseStream); + await this.#streamHandlerResponse(resolvedResponse, 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 reqCtx - The request context containing the response to stream + * @param resolvedResponse - The resolved response and its output metadata * @param responseStream - The Lambda response stream to write to */ async #streamHandlerResponse( - reqCtx: RequestContext, + resolvedResponse: ResolvedResponse, responseStream: ResponseStream ) { const { headers } = webHeadersToApiGatewayHeaders( - reqCtx.res.headers, - reqCtx.responseType + resolvedResponse.res.headers, + resolvedResponse.responseType ); const resStream = HttpResponseStream.from(responseStream, { - statusCode: reqCtx.res.status, + statusCode: resolvedResponse.res.status, headers, }); - if (reqCtx.res.body) { + if (resolvedResponse.res.body) { const nodeStream = Readable.fromWeb( - reqCtx.res.body as streamWeb.ReadableStream + resolvedResponse.res.body as streamWeb.ReadableStream ); await pipeline(nodeStream, resStream); } else { diff --git a/packages/event-handler/src/http/converters.ts b/packages/event-handler/src/http/converters.ts index e96fccbb4b..b0a555ff65 100644 --- a/packages/event-handler/src/http/converters.ts +++ b/packages/event-handler/src/http/converters.ts @@ -10,6 +10,7 @@ import type { } from 'aws-lambda'; import type { BodyInit } from 'undici-types'; import type { + ClassifiedEvent, ExtendedAPIGatewayProxyResult, ExtendedAPIGatewayProxyResultBody, HandlerResponse, @@ -26,9 +27,11 @@ import { HttpVerbs, MULTI_VALUE_HEADERS_ALLOWLIST, } from './constants.js'; -import { InvalidHttpMethodError } from './errors.js'; +import { InvalidEventError, InvalidHttpMethodError } from './errors.js'; +import type { Router } from './Router.js'; import { isALBEvent, + isAPIGatewayProxyEventV1, isAPIGatewayProxyEventV2, isBinaryResult, isExtendedAPIGatewayProxyResult, @@ -38,11 +41,34 @@ import { } from './utils.js'; /** - * Reads and normalises the HTTP method of an event, throwing when it is not one we route. + * Identifies the integration and retains its narrowed event. * - * @param rawMethod - The method as it appears on the event + * @param event - The incoming Lambda event + * @internal */ -const toHttpMethod = (rawMethod: string): HttpMethod => { +const classifyEvent = (event: unknown): ClassifiedEvent => { + if (isAPIGatewayProxyEventV2(event)) { + return { responseType: 'ApiGatewayV2', event }; + } + if (isALBEvent(event)) { + return { responseType: 'ALB', event }; + } + if (isAPIGatewayProxyEventV1(event)) { + return { responseType: 'ApiGatewayV1', event }; + } + throw new InvalidEventError(); +}; + +/** + * Uppercases the event's HTTP method and rejects unsupported methods. + * + * @param classified - The event and its integration + */ +const normalizeHttpMethod = (classified: ClassifiedEvent): HttpMethod => { + const rawMethod = + classified.responseType === 'ApiGatewayV2' + ? classified.event.requestContext.http.method + : classified.event.httpMethod; const method = rawMethod.toUpperCase(); if (!isHttpMethod(method)) { throw new InvalidHttpMethodError(method); @@ -51,7 +77,7 @@ const toHttpMethod = (rawMethod: string): HttpMethod => { }; /** - * Creates a request body from API Gateway event body, handling base64 decoding if needed. + * Preserves text bodies and decodes base64 bodies into bytes. * * GET and HEAD requests are not allowed to carry a body when constructing a * Web API {@link Request | `Request`}, so any body present on the event is ignored for those methods. @@ -64,7 +90,7 @@ const createBody = ( body: string | null, isBase64Encoded: boolean, httpMethod: HttpMethod -) => { +): string | Uint8Array | null => { if (httpMethod === HttpVerbs.GET || httpMethod === HttpVerbs.HEAD) { return null; } @@ -74,24 +100,31 @@ const createBody = ( if (!isBase64Encoded) { return body; } - return Buffer.from(body, 'base64').toString('utf8'); + return Buffer.from(body, 'base64'); }; /** - * Populates headers from single and multi-value header entries. + * Normalizes single-value headers, multi-value headers, and cookies. * - * @param headers - The Headers object to populate - * @param event - The API Gateway proxy event or ALB event + * @param classified - The event and its integration */ -const populateV1Headers = ( - headers: Headers, - event: APIGatewayProxyEvent | ALBEvent -): void => { - for (const [name, value] of Object.entries(event.headers ?? {})) { +const createHeaders = (classified: ClassifiedEvent): Headers => { + const headers = new Headers(); + for (const [name, value] of Object.entries(classified.event.headers ?? {})) { if (value !== undefined) headers.set(name, value); } - for (const [name, values] of Object.entries(event.multiValueHeaders ?? {})) { + if (classified.responseType === 'ApiGatewayV2') { + const { cookies } = classified.event; + if (Array.isArray(cookies)) { + headers.set('Cookie', cookies.join('; ')); + } + return headers; + } + + for (const [name, values] of Object.entries( + classified.event.multiValueHeaders ?? {} + )) { for (const value of values ?? []) { const headerValue = headers.get(name); if (!headerValue?.includes(value)) { @@ -99,13 +132,15 @@ const populateV1Headers = ( } } } + + return headers; }; /** * 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 or ALB event + * @param event - The API Gateway v1 or ALB event */ const populateV1QueryParams = ( url: URL, @@ -129,109 +164,87 @@ const populateV1QueryParams = ( }; /** - * Converts an API Gateway proxy event to a Web API Request object. + * Builds a URL from the structured path and query fields used by v1 and ALB. + * + * Retains the existing URL resolution and query encoding behavior for both sources. * - * @param event - The API Gateway proxy event - * @returns A Web API Request object + * @param event - The API Gateway v1 or ALB event + * @param headers - The normalized request headers + * @param fallbackHostname - The hostname to use when the Host header is absent */ -const proxyEventV1ToWebRequest = (event: APIGatewayProxyEvent): Request => { - const { path } = event; - const { domainName } = event.requestContext; - const method = toHttpMethod(event.httpMethod); - - const headers = new Headers(); - populateV1Headers(headers, event); - - const hostname = headers.get('Host') ?? domainName; +const createStructuredUrl = ( + event: APIGatewayProxyEvent | ALBEvent, + headers: Headers, + fallbackHostname: string | undefined +): URL => { + const hostname = headers.get('Host') ?? fallbackHostname; const protocol = headers.get('X-Forwarded-Proto') ?? 'https'; - const url = new URL(path, `${protocol}://${hostname}/`); + const url = new URL(event.path, `${protocol}://${hostname}/`); populateV1QueryParams(url, event); - - return new Request(url.toString(), { - method, - headers, - body: createBody(event.body, event.isBase64Encoded, method), - }); + return url; }; /** - * Converts an API Gateway V2 proxy event to a Web API Request object. + * Builds a Web URL using the integration's path and query representation. * - * @param event - The API Gateway V2 proxy event - * @returns A Web API Request object + * @param classified - The event and its integration + * @param headers - The normalized request headers */ -const proxyEventV2ToWebRequest = (event: APIGatewayProxyEventV2): Request => { - const { rawPath, rawQueryString } = event; - const { domainName } = event.requestContext; - const method = toHttpMethod(event.requestContext.http.method); - - 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 createUrl = (classified: ClassifiedEvent, headers: Headers): URL => { + switch (classified.responseType) { + case 'ApiGatewayV1': + return createStructuredUrl( + classified.event, + headers, + classified.event.requestContext.domainName + ); + case 'ApiGatewayV2': { + const { event } = classified; + const hostname = headers.get('Host') ?? event.requestContext.domainName; + const protocol = headers.get('X-Forwarded-Proto') ?? 'https'; + const url = `${protocol}://${hostname}${event.rawPath}`; + return new URL( + event.rawQueryString ? `${url}?${event.rawQueryString}` : url + ); + } + case 'ALB': + return createStructuredUrl(classified.event, headers, 'localhost'); } - - 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, method), - }); }; /** - * Converts an ALB event to a Web API Request object. + * Constructs a Web Request from an already-classified event. * - * @param event - The ALB event - * @returns A Web API Request object + * @param classified - The event and its integration + * @internal */ -const albEventToWebRequest = (event: ALBEvent): Request => { - const { path } = event; - const method = toHttpMethod(event.httpMethod); - - 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); - - return new Request(url.toString(), { +const classifiedEventToWebRequest = (classified: ClassifiedEvent): Request => { + const method = normalizeHttpMethod(classified); + const headers = createHeaders(classified); + const url = createUrl(classified, headers); + return new Request(url, { method, headers, - body: createBody(event.body ?? null, event.isBase64Encoded, method), + body: createBody( + classified.event.body ?? null, + classified.event.isBase64Encoded, + method + ), }); }; /** * 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. + * Automatically detects the integration and normalizes its request fields. * + * @deprecated This converter is an implementation detail and will be removed in a future major version. Access `reqCtx.req` in {@link Router | `Router`} handlers or middleware instead. * @param event - The API Gateway proxy event (V1 or V2) or ALB event - * @returns A Web API Request object */ const proxyEventToWebRequest = ( event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBEvent ): Request => { - if (isAPIGatewayProxyEventV2(event)) { - return proxyEventV2ToWebRequest(event); - } - if (isALBEvent(event)) { - return albEventToWebRequest(event); - } - return proxyEventV1ToWebRequest(event); + return classifiedEventToWebRequest(classifyEvent(event)); }; /** @@ -585,6 +598,8 @@ const bodyToNodeStream = (body: ExtendedAPIGatewayProxyResultBody) => { export { bodyToNodeStream, + classifiedEventToWebRequest, + classifyEvent, handlerResultToWebResponse, proxyEventToWebRequest, webHeadersToApiGatewayHeaders, diff --git a/packages/event-handler/src/types/http.ts b/packages/event-handler/src/types/http.ts index 05159927c2..f8067baa39 100644 --- a/packages/event-handler/src/types/http.ts +++ b/packages/event-handler/src/types/http.ts @@ -124,6 +124,18 @@ type EventTypeMap = { ALB: ALBEvent; }; +/** + * Associates an incoming event with its integration's response format. + * + * @internal + */ +type ClassifiedEvent = { + [T in keyof EventTypeMap]: { + responseType: T; + event: EventTypeMap[T]; + }; +}[keyof EventTypeMap]; + type ResponseTypeMap = { ApiGatewayV1: APIGatewayProxyResult; ApiGatewayV2: APIGatewayProxyStructuredResultV2; @@ -611,6 +623,7 @@ type HandlerOrOptions< export type { BinaryResult, + ClassifiedEvent, CompiledRoute, CompressionOptions, CorsOptions, diff --git a/packages/event-handler/tests/unit/http/Router/error-handling.test.ts b/packages/event-handler/tests/unit/http/Router/error-handling.test.ts index 533ee4f97c..a0911e7db1 100644 --- a/packages/event-handler/tests/unit/http/Router/error-handling.test.ts +++ b/packages/event-handler/tests/unit/http/Router/error-handling.test.ts @@ -678,32 +678,3 @@ describe.each([ }); }); }); -describe('Class: Router - proxyEventToWebRequest Error Handling', () => { - beforeEach(() => { - vi.resetModules(); - }); - - it('re-throws non-InvalidHttpMethodError from proxyEventToWebRequest', async () => { - // Prepare - vi.doMock('../../../../src/http/converters.js', async () => { - const actual = await vi.importActual< - typeof import('../../../../src/http/converters.js') - >('../../../../src/http/converters.js'); - return { - ...actual, - proxyEventToWebRequest: vi.fn(() => { - throw new TypeError('Unexpected error'); - }), - }; - }); - - const { Router } = await import('../../../../src/http/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/http/Router/request-normalization.test.ts b/packages/event-handler/tests/unit/http/Router/request-normalization.test.ts new file mode 100644 index 0000000000..ca848e4f68 --- /dev/null +++ b/packages/event-handler/tests/unit/http/Router/request-normalization.test.ts @@ -0,0 +1,331 @@ +import context from '@aws-lambda-powertools/testing-utils/context'; +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { Router } from '../../../../src/http/index.js'; +import { + createTestALBEvent, + createTestEvent, + createTestEventV2, +} from '../helpers.js'; + +describe.each([ + { responseType: 'ApiGatewayV1', createEvent: createTestEvent }, + { responseType: 'ApiGatewayV2', createEvent: createTestEventV2 }, + { responseType: 'ALB', createEvent: createTestALBEvent }, +])('Request normalization ($responseType)', ({ responseType, createEvent }) => { + it('preserves binary bytes in a base64 request body', async () => { + // Prepare + const app = new Router(); + const bytes = Buffer.from([0x00, 0x7f, 0x80, 0xff, 0xc3, 0x28]); + const event = createEvent('/upload', 'POST', { + 'content-type': 'application/octet-stream', + }); + event.body = bytes.toString('base64'); + event.isBase64Encoded = true; + app.post('/upload', async ({ req }) => ({ + bytes: Array.from(new Uint8Array(await req.arrayBuffer())), + contentType: req.headers.get('content-type'), + })); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body ?? '')).toEqual({ + bytes: Array.from(bytes), + contentType: 'application/octet-stream', + }); + }); + + it('preserves binary file bytes in a multipart request body', async () => { + // Prepare + const app = new Router(); + const bytes = Buffer.from([0x00, 0x80, 0xff, 0xfe]); + const boundary = 'powertools-upload'; + const body = Buffer.concat([ + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="upload"; filename="file.bin"\r\nContent-Type: application/octet-stream\r\n\r\n` + ), + bytes, + Buffer.from(`\r\n--${boundary}--\r\n`), + ]); + const event = createEvent('/upload', 'POST', { + 'content-type': `multipart/form-data; boundary=${boundary}`, + }); + event.body = body.toString('base64'); + event.isBase64Encoded = true; + app.post('/upload', async ({ req }) => { + const form = await req.formData(); + const file = form.get('upload'); + return file instanceof File + ? Array.from(new Uint8Array(await file.arrayBuffer())) + : null; + }); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body ?? '')).toEqual(Array.from(bytes)); + }); + + it.each([ + { isBase64Encoded: false, contentType: 'text/plain;charset=UTF-8' }, + { isBase64Encoded: true, contentType: null }, + ])( + 'uses the expected default content type for base64=$isBase64Encoded', + async ({ isBase64Encoded, contentType }) => { + // Prepare + const app = new Router(); + const text = 'Hello, 世界'; + const event = createEvent('/body', 'POST'); + event.body = isBase64Encoded + ? Buffer.from(text).toString('base64') + : text; + event.isBase64Encoded = isBase64Encoded; + app.post('/body', async ({ req }) => ({ + text: await req.text(), + contentType: req.headers.get('content-type'), + })); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(JSON.parse(result.body ?? '')).toEqual({ text, contentType }); + } + ); + + it.each(['get', 'head'])('ignores a base64 body for %s', async (method) => { + // Prepare + const app = new Router(); + const handler = ({ req }: { req: Request }) => ({ + hasBody: req.body !== null, + }); + app.get('/body', handler); + app.head('/body', handler); + const event = createEvent('/body', method); + event.body = Buffer.from([0xff, 0x00]).toString('base64'); + event.isBase64Encoded = true; + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body ?? '')).toEqual({ hasBody: false }); + }); + + it('routes a lowercase patch method with the original event context', async () => { + // Prepare + const app = new Router(); + const event = createEvent('/test', 'patch'); + app.patch('/test', (reqCtx) => ({ + method: reqCtx.req.method, + responseType: reqCtx.responseType, + sameEvent: reqCtx.event === event, + sameContext: reqCtx.context === context, + })); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body ?? '')).toEqual({ + method: 'PATCH', + responseType, + sameEvent: true, + sameContext: true, + }); + }); + + it('validates a base64 JSON request and its response', async () => { + // Prepare + const app = new Router(); + const event = createEvent('/users', 'POST', { + 'content-type': 'application/json', + }); + event.body = Buffer.from(JSON.stringify({ name: '世界' })).toString( + 'base64' + ); + event.isBase64Encoded = true; + app.post( + '/users', + async (reqCtx) => ({ + name: reqCtx.valid.req.body.name, + original: await reqCtx.req.text(), + }), + { + validation: { + req: { body: z.object({ name: z.string() }) }, + res: { + body: z.object({ + name: z.string(), + original: z.string(), + }), + }, + }, + } + ); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body ?? '')).toEqual({ + name: '世界', + original: JSON.stringify({ name: '世界' }), + }); + }); + + it.each<{ headers: Record; failure: string }>([ + { headers: { Host: 'invalid host' }, failure: 'URL' }, + { headers: { 'invalid header': 'value' }, failure: 'header' }, + ])( + 'rejects $failure construction errors before middleware', + async ({ headers }) => { + // Prepare + const app = new Router(); + const middleware = vi.fn(); + const errorHandler = vi.fn(async () => ({ handled: true })); + app.use(middleware); + app.errorHandler(TypeError, errorHandler); + const event = createEvent('/test', 'GET', headers); + + // Act & Assess + await expect(app.resolve(event, context)).rejects.toThrow(TypeError); + expect(middleware).not.toHaveBeenCalled(); + expect(errorHandler).not.toHaveBeenCalled(); + } + ); + + it('returns 405 before constructing headers or the URL', async () => { + // Prepare + const app = new Router(); + const middleware = vi.fn(); + const errorHandler = vi.fn(async () => ({ handled: true })); + app.use(middleware); + app.errorHandler(Error, errorHandler); + const event = createEvent('/test', 'TRACE', { + Host: 'invalid host', + 'invalid header': 'value', + }); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(405); + expect(result.body).toBe(''); + expect(middleware).not.toHaveBeenCalled(); + expect(errorHandler).not.toHaveBeenCalled(); + }); +}); + +describe.each([ + { version: 'V1', createEvent: createTestEvent }, + { version: 'ALB', createEvent: createTestALBEvent }, +])('Structured request URLs ($version)', ({ createEvent }) => { + it('preserves repeated query values and their encoding', async () => { + // Prepare + const app = new Router(); + const event = createEvent('/test', 'GET', { Host: 'api.example.com' }); + event.queryStringParameters = { + tag: 'second', + space: 'a b', + encoded: 'a%20b', + }; + event.multiValueQueryStringParameters = { + tag: ['first', 'second', 'first'], + }; + app.get('/test', ({ req }) => ({ url: req.url })); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(JSON.parse(result.body ?? '')).toEqual({ + url: 'https://api.example.com/test?space=a+b&encoded=a%2520b&tag=first&tag=second&tag=first', + }); + }); + + it('preserves the existing authority resolution for a path starting with two slashes', async () => { + // Prepare + const app = new Router(); + const event = createEvent('//other.example/test', 'GET', { + Host: 'api.example.com', + }); + app.get('/test', ({ req }) => ({ url: req.url })); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(JSON.parse(result.body ?? '')).toEqual({ + url: 'https://other.example/test', + }); + }); +}); + +describe('Raw request URLs and cookies (V2)', () => { + it('preserves raw query spelling and repeated values', async () => { + // Prepare + const app = new Router(); + const event = createTestEventV2('/test', 'GET'); + event.rawQueryString = 'q=a%20b&literal=%2f&tag=one&tag=two&flag&tilde=~'; + event.queryStringParameters = { tag: 'one,two' }; + app.get('/test', ({ req }) => ({ url: req.url })); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(JSON.parse(result.body ?? '')).toEqual({ + url: `https://api.example.com/test?${event.rawQueryString}`, + }); + }); + + it('keeps a path starting with two slashes on the original host', async () => { + // Prepare + const app = new Router(); + const event = createTestEventV2('//other.example/test', 'GET'); + app.get('//other.example/test', ({ req }) => ({ url: req.url })); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(JSON.parse(result.body ?? '')).toEqual({ + url: 'https://api.example.com//other.example/test', + }); + }); + + it.each([ + { cookies: [], expected: '' }, + { + cookies: ['session=abc', 'theme=dark'], + expected: 'session=abc; theme=dark', + }, + ])( + 'uses the cookies array over the Cookie header: $expected', + async ({ cookies, expected }) => { + // Prepare + const app = new Router(); + const event = createTestEventV2('/test', 'GET', { + Cookie: 'previous=value', + }); + event.cookies = cookies; + app.get('/test', ({ req }) => ({ cookie: req.headers.get('cookie') })); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(JSON.parse(result.body ?? '')).toEqual({ cookie: expected }); + } + ); +});