What happens
parseBody in src/lambda/handler.ts only JSON-parses a request body when it finds the
header key exactly content-type (lowercase):
function parseBody(event) {
let body = event.body;
if (event.body && event.isBase64Encoded) {
const buff = Buffer.from(event.body, 'base64');
body = buff.toString('utf8');
}
// If the request body is in JSON format, parse it into a JavaScript object
if (event.headers && event.headers['content-type']?.includes('application/json')) {
return JSON.parse(body ?? '{}');
}
// If the request body is not present or not in JSON format, return it as-is
return body;
}
API Gateway REST (v1) proxy integrations pass request headers through with the client's
original casing. A client sending the conventional
Content-Type: application/json
therefore misses this lookup, and the handler receives the body as a raw string instead of
the parsed object its type declares.
Why it is easy to miss
createOpenApiHandlerWithRequestBody<OP> types the second handler argument as
OP['requestBody']['content']['application/json'] — an object. So the handler compiles, and at
runtime it silently receives a string. Any handler that reads fields off that argument sees
undefined for all of them.
In our case (a PUT that records a sports result) this produced a 200/204 response with the
submitted data silently discarded — the worst failure mode, because the client is told it
succeeded. There is no error, no log line, and nothing to correlate later.
Reproduction
export const handler = api.createOpenApiHandlerWithRequestBodyNoResponse<operations['updateThing']>(
async (ctx, body) => {
ctx.logger.info(typeof body); // 'object' with `content-type`, 'string' with `Content-Type`
ctx.response.statusCode = 204;
},
);
Invoke twice with the same JSON payload, varying only the header casing.
Directly against the test utility:
await new LambdaRestUnitTest(handler).call({
method: 'PUT',
headers: { 'Content-Type': 'application/json' }, // canonical casing
body: JSON.stringify({ present: true }),
});
Suggested fix
Look the header up case-insensitively, e.g.:
function headerValue(headers: Record<string, string | undefined> | null, name: string) {
if (!headers) return undefined;
const key = Object.keys(headers).find(k => k.toLowerCase() === name.toLowerCase());
return key ? headers[key] : undefined;
}
if (headerValue(event.headers, 'content-type')?.includes('application/json')) {
return JSON.parse(body ?? '{}');
}
HTTP header names are case-insensitive per RFC 9110 §5.1, so any lookup against
event.headers needs this treatment. multiValueHeaders has the same property.
Two things worth considering alongside it:
- Fail loudly rather than passing a string through. When the operation's contract declares
an application/json request body but the incoming body cannot be parsed as JSON, a 400
would be safer than handing the handler a value that does not match its declared type.
LambdaRestUnitTest inherits the behaviour, so tests written with the canonical casing
reproduce it — which is how we found it.
Workaround
Normalising defensively in application code:
export function parseRequestBody<T extends object>(body: unknown): Partial<T> {
if (!body) return {};
if (typeof body === 'string') {
try {
const parsed = JSON.parse(body);
return typeof parsed === 'object' && parsed !== null ? parsed as Partial<T> : {};
} catch {
return {};
}
}
return typeof body === 'object' ? body as Partial<T> : {};
}
Environment
cdk-serverless 3.0.0
- API Gateway REST API (
SpecRestApi) with a Lambda proxy integration
- Node.js 22
What happens
parseBodyinsrc/lambda/handler.tsonly JSON-parses a request body when it finds theheader key exactly
content-type(lowercase):API Gateway REST (v1) proxy integrations pass request headers through with the client's
original casing. A client sending the conventional
therefore misses this lookup, and the handler receives the body as a raw string instead of
the parsed object its type declares.
Why it is easy to miss
createOpenApiHandlerWithRequestBody<OP>types the second handler argument asOP['requestBody']['content']['application/json']— an object. So the handler compiles, and atruntime it silently receives a string. Any handler that reads fields off that argument sees
undefinedfor all of them.In our case (a PUT that records a sports result) this produced a 200/204 response with the
submitted data silently discarded — the worst failure mode, because the client is told it
succeeded. There is no error, no log line, and nothing to correlate later.
Reproduction
Invoke twice with the same JSON payload, varying only the header casing.
Directly against the test utility:
Suggested fix
Look the header up case-insensitively, e.g.:
HTTP header names are case-insensitive per RFC 9110 §5.1, so any lookup against
event.headersneeds this treatment.multiValueHeadershas the same property.Two things worth considering alongside it:
an
application/jsonrequest body but the incoming body cannot be parsed as JSON, a 400would be safer than handing the handler a value that does not match its declared type.
LambdaRestUnitTestinherits the behaviour, so tests written with the canonical casingreproduce it — which is how we found it.
Workaround
Normalising defensively in application code:
Environment
cdk-serverless3.0.0SpecRestApi) with a Lambda proxy integration