diff --git a/packages/aws-lambda/README.md b/packages/aws-lambda/README.md index 4e1498d..6d03cd7 100644 --- a/packages/aws-lambda/README.md +++ b/packages/aws-lambda/README.md @@ -46,7 +46,7 @@ The package exposes these helpers: | Group | Exports | Purpose | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | | Request and response | `toStandardLazyRequest()`, `sendStandardResponse()` | Adapt Lambda events and response streams to Standard Server | -| Lower-level helpers | `toStandardUrl()`, `toStandardHeaders()`, `getEventHeader()`, `toStandardBody()`, `toLambdaHeaders()` | Convert individual pieces of an event | +| Lower-level helpers | `toStandardUrl()`, `toStandardHeaders()`, `toStandardBody()`, `toLambdaHeaders()` | Convert individual pieces of an event | | Types and option shapes | `APIGatewayProxyEvent`, `APIGatewayProxyEventV2`, `AnyAPIGatewayProxyEvent`, `HttpResponseStream`, `AwsLambdaGlobal`, `SendStandardResponseOptions` | Type handler inputs and serializer options | `APIGatewayProxyEvent` and `APIGatewayProxyEventV2` are structural subsets of the same-named types from `@types/aws-lambda`, so events typed with either work; the adapter accepts both via `AnyAPIGatewayProxyEvent` and tells them apart by the top-level `httpMethod` field only payload format 1.0 carries. `AwsLambdaGlobal` describes the `awslambda` global the Lambda Node.js runtime injects — the package deliberately does not `declare global`, so importing it never pollutes your project's global types. Declare the global yourself where you need typed access to `awslambda.streamifyResponse`. diff --git a/packages/aws-lambda/src/body.test.ts b/packages/aws-lambda/src/body.test.ts index e040920..3ad660d 100644 --- a/packages/aws-lambda/src/body.test.ts +++ b/packages/aws-lambda/src/body.test.ts @@ -1,5 +1,5 @@ import type { AsyncIteratorClass } from '@standard-server/shared' -import type { APIGatewayProxyEvent } from './types' +import type { AnyAPIGatewayProxyEvent, APIGatewayProxyEvent } from './types' import { Buffer } from 'node:buffer' import { toStandardBody } from './body' @@ -173,6 +173,26 @@ describe('toStandardBody', () => { await expect(standardBody.text()).resolves.toBe('hello') }) + it.each<[string, AnyAPIGatewayProxyEvent]>([ + ['v1 headers fallback', event({ + body: 'hello', + multiValueHeaders: { 'X-Other': ['ignored'] }, + headers: { 'Content-Type': 'text/plain', 'Content-Disposition': 'inline; filename="hello.txt"' }, + })], + ['v2', { + rawPath: '/', + requestContext: { http: { method: 'POST' } }, + body: 'hello', + isBase64Encoded: false, + headers: { 'Content-Type': 'text/plain', 'Content-Disposition': 'inline; filename="hello.txt"' }, + }], + ])('reads content headers from single-value headers (%s)', async (_, e) => { + const standardBody = await toStandardBody(e) as File + + expect(standardBody.name).toBe('hello.txt') + expect(standardBody.type).toBe('text/plain') + }) + it('respects the file hint over the content-type', async () => { const standardBody = await toStandardBody(event({ body: '{"foo":"bar"}', diff --git a/packages/aws-lambda/src/body.ts b/packages/aws-lambda/src/body.ts index 572a3af..481b98b 100644 --- a/packages/aws-lambda/src/body.ts +++ b/packages/aws-lambda/src/body.ts @@ -4,7 +4,7 @@ import { Buffer } from 'node:buffer' import { flattenStandardHeader, getFilenameFromContentDisposition, resolveStandardBodyHint } from '@standard-server/core' import { toAsyncIteratorObject } from '@standard-server/fetch' import { parseEmptyableJSON } from '@standard-server/shared' -import { getEventHeader } from './headers' +import { toStandardHeaders } from './headers' export interface ToStandardBodyOptions { /** @@ -20,12 +20,8 @@ export async function toStandardBody( event: AnyAPIGatewayProxyEvent, options: ToStandardBodyOptions = {}, ): Promise { - const hint = options?.hint ?? resolveStandardBodyHint({ - 'standard-server': getEventHeader(event, 'standard-server'), - 'content-type': getEventHeader(event, 'content-type'), - 'content-length': getEventHeader(event, 'content-length'), - 'content-disposition': getEventHeader(event, 'content-disposition'), - }) + const headers = toStandardHeaders(event) + const hint = options.hint ?? resolveStandardBodyHint(headers) if (hint === 'none') { return undefined @@ -41,7 +37,7 @@ export async function toStandardBody( return parseEmptyableJSON(new TextDecoder().decode(bytes)) } - const contentType = flattenStandardHeader(getEventHeader(event, 'content-type')) + const contentType = flattenStandardHeader(headers['content-type']) if (hint === 'form-data') { return _bytesToFormData(bytes, contentType) @@ -56,7 +52,7 @@ export async function toStandardBody( } if (hint === 'file') { - const contentDisposition = flattenStandardHeader(getEventHeader(event, 'content-disposition')) + const contentDisposition = flattenStandardHeader(headers['content-disposition']) const fileName = contentDisposition !== undefined ? getFilenameFromContentDisposition(contentDisposition) : undefined diff --git a/packages/aws-lambda/src/headers.test.ts b/packages/aws-lambda/src/headers.test.ts index 5f6706b..0e4aa7a 100644 --- a/packages/aws-lambda/src/headers.test.ts +++ b/packages/aws-lambda/src/headers.test.ts @@ -1,84 +1,4 @@ -import { getEventHeader, toLambdaHeaders, toStandardHeaders } from './headers' - -describe('getEventHeader', () => { - it('reads case-insensitively from multiValueHeaders (v1)', () => { - const event = { - httpMethod: 'GET', - path: '/', - headers: { 'content-type': 'ignored' }, - multiValueHeaders: { - 'Content-Type': ['application/json'], - 'X-Multi': ['one', 'two'], - 'X-Empty': [], - 'X-Skipped': undefined, - }, - } - - expect(getEventHeader(event, 'Content-Type')).toEqual(['application/json']) - expect(getEventHeader(event, 'x-multi')).toEqual(['one', 'two']) - expect(getEventHeader(event, 'x-empty')).toBeUndefined() - expect(getEventHeader(event, 'x-skipped')).toBeUndefined() - expect(getEventHeader(event, 'x-missing')).toBeUndefined() - }) - - it('falls through to headers for keys multiValueHeaders does not carry (v1)', () => { - expect(getEventHeader({ - httpMethod: 'GET', - path: '/', - headers: { 'X-Only-In-Headers': 'kept' }, - multiValueHeaders: { 'Content-Type': ['application/json'] }, - }, 'x-only-in-headers')).toBe('kept') - }) - - it('reads case-insensitively from headers (v1 fallback and v2)', () => { - const event = { - rawPath: '/', - requestContext: { http: { method: 'GET' } }, - headers: { - 'Content-Type': 'application/json', - 'X-Skipped': undefined, - }, - } - - expect(getEventHeader(event, 'Content-Type')).toBe('application/json') - expect(getEventHeader(event, 'x-skipped')).toBeUndefined() - expect(getEventHeader(event, 'x-missing')).toBeUndefined() - - expect(getEventHeader({ - httpMethod: 'GET', - path: '/', - multiValueHeaders: null, - headers: { 'X-Custom': 'value' }, - }, 'x-custom')).toBe('value') - }) - - it('returns undefined when no headers are present', () => { - expect(getEventHeader({ httpMethod: 'GET', path: '/' }, 'content-type')).toBeUndefined() - expect(getEventHeader({ rawPath: '/', requestContext: { http: { method: 'GET' } } }, 'content-type')).toBeUndefined() - }) - - it('restores the cookie header from cookies (v2)', () => { - expect(getEventHeader({ - rawPath: '/', - requestContext: { http: { method: 'GET' } }, - cookies: ['foo=bar', 'bar=baz'], - }, 'Cookie')).toBe('foo=bar; bar=baz') - - // a cookie header present in headers wins over the cookies field - expect(getEventHeader({ - rawPath: '/', - requestContext: { http: { method: 'GET' } }, - headers: { Cookie: 'a=b' }, - cookies: ['foo=bar'], - }, 'cookie')).toBe('a=b') - - expect(getEventHeader({ - rawPath: '/', - requestContext: { http: { method: 'GET' } }, - cookies: [], - }, 'cookie')).toBeUndefined() - }) -}) +import { toLambdaHeaders, toStandardHeaders } from './headers' describe('toStandardHeaders (v2)', () => { it('lowercases keys and restores the cookie header', () => { @@ -98,6 +18,17 @@ describe('toStandardHeaders (v2)', () => { }) }) + it('prefers a cookie header over the cookies field', () => { + expect(toStandardHeaders({ + rawPath: '/', + requestContext: { http: { method: 'GET' } }, + headers: { Cookie: 'a=b' }, + cookies: ['foo=bar'], + })).toEqual({ + cookie: 'a=b', + }) + }) + it('ignores empty or missing cookies', () => { expect(toStandardHeaders({ rawPath: '/', diff --git a/packages/aws-lambda/src/headers.ts b/packages/aws-lambda/src/headers.ts index 570f390..4019cae 100644 --- a/packages/aws-lambda/src/headers.ts +++ b/packages/aws-lambda/src/headers.ts @@ -60,37 +60,6 @@ export function toStandardHeaders(event: AnyAPIGatewayProxyEvent): StandardHeade return standardHeaders } -/** - * Read a single header from an API Gateway proxy event, case-insensitively. - */ -export function getEventHeader(event: AnyAPIGatewayProxyEvent, key: string): string | string[] | undefined { - key = key.toLowerCase() - - if ('httpMethod' in event && event.multiValueHeaders) { - for (const k of Object.keys(event.multiValueHeaders)) { - const headerValues = event.multiValueHeaders[k] - if (headerValues !== undefined && headerValues.length !== 0 && k.toLowerCase() === key) { - return headerValues - } - } - } - - if (event.headers) { - for (const k of Object.keys(event.headers)) { - const headerValue = event.headers[k] - if (headerValue !== undefined && k.toLowerCase() === key) { - return headerValue - } - } - } - - if (key === 'cookie' && !('httpMethod' in event) && event.cookies?.length) { - return event.cookies.join('; ') - } - - return undefined -} - /** * Split standard headers into the `headers` and `cookies` metadata fields. * `set-cookie` values are kept separate because joining them would corrupt them.