-
Notifications
You must be signed in to change notification settings - Fork 0
Event shapes
AWS Lambda delivers HTTP-shaped events through four different payload formats depending on the trigger. The adapter auto-detects the shape on each invocation and returns the matching result envelope — a single handler works for all of them. This page explains how the detection works, how each shape maps onto the adapter's internals, and the gotchas worth knowing.
The factory's returned handler sniffs the shape from the event itself:
// src/index.js
const detectKind = event => {
if (event?.requestContext?.elb) return 'alb';
if (event?.version === '2.0') return 'v2';
return 'v1';
};-
ALB events carry
event.requestContext.elb(the target-group ARN marker). Checked first because an ALB event might otherwise look like v1. -
v2 / Function URL events carry
event.version === '2.0'— the payload-format marker added by API Gateway HTTP API and Lambda Function URLs. - Everything else is treated as API Gateway REST v1.
Unrecognized events fall through as v1; feeding the handler a bare object that doesn't look like any of the three will produce garbage, but there's no sniff for "this isn't an HTTP event at all." The handler is designed for HTTP triggers.
Each event shape has a matching result shape. The adapter writes the right one:
| Event shape | Type (aws-lambda) |
Envelope fields |
|---|---|---|
| API Gateway REST v1 | APIGatewayProxyResult |
{statusCode, body, headers?, multiValueHeaders?, isBase64Encoded?} |
| API Gateway HTTP v2 / Function URL | APIGatewayProxyStructuredResultV2 |
{statusCode, body, headers?, cookies?, isBase64Encoded?} |
| ALB | ALBResult |
{statusCode, statusDescription?, body, headers?, multiValueHeaders?, isBase64Encoded?} |
The three overlap on {statusCode, body, headers?, multiValueHeaders?}, so the adapter emits a single shape that satisfies whichever envelope the trigger expects. Consumers rarely have to narrow the union.
| Concept | API Gateway v1 | API Gateway v2 / Function URL | ALB |
|---|---|---|---|
| Method | event.httpMethod |
event.requestContext.http.method |
event.httpMethod |
| Path |
event.path (stage prefix stripped) |
event.rawPath |
event.path (listener path) |
| Query (flat) |
event.queryStringParameters (may be null) |
event.queryStringParameters (may be null) |
event.queryStringParameters (may be null) |
| Query (multi) | event.multiValueQueryStringParameters |
n/a (HTTP API collapses duplicates with commas) |
event.multiValueQueryStringParameters (multi-value mode only) |
| Body | event.body: string | null |
event.body: string | null |
event.body: string | null |
| Body base64? | event.isBase64Encoded |
event.isBase64Encoded |
event.isBase64Encoded |
| Headers |
event.headers + event.multiValueHeaders
|
event.headers (comma-joined duplicates) |
event.headers OR event.multiValueHeaders (exclusive) |
| Cookies | event.headers.cookie |
event.cookies: string[] |
event.headers.cookie |
| Path stripping | API Gateway strips stage before dispatch | HTTP API strips stage automatically | None — raw listener path passes through |
| Response shape | APIGatewayProxyResult |
APIGatewayProxyStructuredResultV2 |
ALBResult with multiValueHeaders when mirror'd |
Pagination links need the caller's full path + query so clients can follow next / prev without reconstructing a base URL. The adapter preserves whatever the event delivered:
// src/index.js (simplified)
const urlBuilderFor = (event, kind) => ({offset, limit}) => {
const originalPath = kind === 'v2' ? event.rawPath : event.path;
const sp = new URLSearchParams(serializeQuery(event, kind));
sp.set('offset', String(offset));
sp.set('limit', String(limit));
const out = sp.toString();
return out ? `${originalPath}?${out}` : originalPath;
};-
v2 uses
rawQueryStringdirectly — the order and encoding match the original request byte-for-byte. -
v1 / ALB iterate
multiValueQueryStringParameterswhen available (preserves duplicates), elsequeryStringParameters(first-value collapse).
Paths include mountPath — pagination links remain valid for clients traversing the mounted adapter.
ALB and API Gateway REST v1 both support a multi-value-headers mode:
-
Request — every header is delivered as a
string[]inevent.multiValueHeaders,event.headersis stampednull, and duplicate headers (Set-Cookie, repeated custom headers) survive. -
Response — the trigger strictly requires the response in the same shape: return
result.multiValueHeaders, notresult.headers. Mixing raises a platform error.
The adapter detects this case and flips the response shape automatically:
// src/index.js
const wantsMultiValueHeaders = (event, kind) => {
if (kind === 'v2') return false;
return !!(event.multiValueHeaders && !event.headers);
};Auto-detection means the handler is the same regardless of trigger — no flag, no per-shape factory. The only knob is on the trigger side (for ALB: lambda.multi_value_headers.enabled; for API Gateway REST: the stage's "Use Multi Value Headers" box).
API Gateway v1 without the multi-value mode delivers both forms (headers populated + multiValueHeaders populated), and accepts either on the response — the adapter defaults to headers for simpler downstream logging. API Gateway HTTP v2 / Function URL have no multi-value mode at all; duplicate request headers collapse to a comma-joined value and the response always uses headers.
Cookies arrive in different places per shape:
-
v1 / ALB — in
event.headers.cookie(orevent.multiValueHeaders.cookiein multi-value mode).Cookie: a=1; b=2stays as one header. -
v2 / Function URL — in
event.cookies: string[]. Each array entry is onename=valuepair, pre-split by the trigger.
The adapter flattens v2's event.cookies into event.headers.cookie before dispatching so exampleFromContext sees one consistent shape:
// src/index.js
const flattenV2Cookies = event => {
if (!event.cookies || !event.cookies.length) return;
const headers = event.headers || (event.headers = {});
const joined = event.cookies.join('; ');
headers.cookie = headers.cookie ? `${headers.cookie}; ${joined}` : joined;
};The mutation is in-place and harmless — Lambda events are not reused across invocations. Reading event.headers.cookie works uniformly; you never need to branch on event shape for cookies.
Response cookies (setting cookies on the way out) aren't something the adapter does for its own routes — it's a stateless REST handler. If you layer cookies on top (wrapping the handler), set them per event shape: result.multiValueHeaders['set-cookie'] on ALB/v1-multi, result.headers['set-cookie'] on v1-single, and result.cookies on v2.
All four shapes carry queryStringParameters with a first-value-wins flattening. v1 / ALB (in multi-value mode) additionally carry multiValueQueryStringParameters with the full arrays. The adapter builds the parser input with a first-value-wins policy, matching the other adapters:
// src/index.js (simplified)
const coerceQuery = (event, kind) => {
const out = {};
if (kind !== 'v2' && event.multiValueQueryStringParameters) {
for (const [k, vs] of Object.entries(event.multiValueQueryStringParameters)) {
if (vs?.length && !(k in out)) out[k] = vs[0];
}
}
if (event.queryStringParameters) {
for (const [k, v] of Object.entries(event.queryStringParameters)) {
if (!(k in out)) out[k] = v;
}
}
return out;
};Why prefer multiValueQueryStringParameters when both are present? Because AWS sometimes populates only one of the two on ALB — the adapter reads multi-value first so duplicated entries resolve consistently, then falls back to the single-value bag.
If you need all values for a repeated query param (e.g. ?tag=a&tag=b), read them yourself from event.multiValueQueryStringParameters inside exampleFromContext. The toolkit's route handlers only need single-value semantics.
Lambda bodies arrive as strings. Binary bodies (and sometimes text bodies, depending on trigger config) are base64-encoded with event.isBase64Encoded: true. The body reader handles this transparently:
// src/read-lambda-body.js
if (isBase64Encoded) {
const bytes = Buffer.from(body, 'base64');
if (bytes.length > maxBodyBytes) throw /* 413 */;
text = bytes.toString('utf-8');
} else {
const byteLength = Buffer.byteLength(body, 'utf-8');
if (byteLength > maxBodyBytes) throw /* 413 */;
text = body;
}Cap is enforced on decoded bytes, so a 5 MB JSON body that arrives as a 6.7 MB base64 string correctly triggers the cap set at 5 MB. See Body reading.
-
Custom authorizers / IAM. The trigger handles auth before the Lambda fires. Claims arrive on
event.requestContext.authorizer.*and are available toexampleFromContextfor tenant scoping — see Options. -
CORS preflights. API Gateway HTTP API has native CORS config; add it there. On ALB / REST, attach a wrapper that handles
OPTIONS /*before delegating to the adapter. - Cold-start warmup. The factory runs on import. Everything expensive (DynamoDB client, policy merge, keyFromPath) is captured in the closure and reused across warm invocations.
-
Binary response bodies. The adapter only emits JSON responses — bodies are already UTF-8 strings. If you layer a handler that emits binary output, set
isBase64Encoded: trueand encode yourself; the adapter doesn't know about binary responses.