Skip to content

Commit 09aa287

Browse files
authored
fix(parser): LambdaFunctionUrl envelope assumes JSON string in body (#3514)
1 parent a4846af commit 09aa287

File tree

7 files changed

+210
-102
lines changed

7 files changed

+210
-102
lines changed

Diff for: packages/parser/src/envelopes/lambda.ts

+22-21
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ZodSchema, z } from 'zod';
22
import { ParseError } from '../errors.js';
33
import { LambdaFunctionUrlSchema } from '../schemas/index.js';
44
import type { ParsedResult } from '../types/index.js';
5-
import { Envelope, envelopeDiscriminator } from './envelope.js';
5+
import { envelopeDiscriminator } from './envelope.js';
66

77
/**
88
* Lambda function URL envelope to extract data within body key
@@ -14,37 +14,38 @@ export const LambdaFunctionUrlEnvelope = {
1414
*/
1515
[envelopeDiscriminator]: 'object' as const,
1616
parse<T extends ZodSchema>(data: unknown, schema: T): z.infer<T> {
17-
const parsedEnvelope = LambdaFunctionUrlSchema.parse(data);
18-
19-
if (!parsedEnvelope.body) {
20-
throw new Error('Body field of Lambda function URL event is undefined');
17+
try {
18+
return LambdaFunctionUrlSchema.extend({
19+
body: schema,
20+
}).parse(data).body;
21+
} catch (error) {
22+
throw new ParseError('Failed to parse Lambda function URL body', {
23+
cause: error as Error,
24+
});
2125
}
22-
23-
return Envelope.parse(parsedEnvelope.body, schema);
2426
},
2527

26-
safeParse<T extends ZodSchema>(data: unknown, schema: T): ParsedResult<unknown, z.infer<T>> {
27-
const parsedEnvelope = LambdaFunctionUrlSchema.safeParse(data);
28-
29-
if (!parsedEnvelope.success) {
30-
return {
31-
success: false,
32-
error: new ParseError('Failed to parse Lambda function URL envelope'),
33-
originalEvent: data,
34-
};
35-
}
28+
safeParse<T extends ZodSchema>(
29+
data: unknown,
30+
schema: T
31+
): ParsedResult<unknown, z.infer<T>> {
32+
const results = LambdaFunctionUrlSchema.extend({
33+
body: schema,
34+
}).safeParse(data);
3635

37-
const parsedBody = Envelope.safeParse(parsedEnvelope.data.body, schema);
38-
if (!parsedBody.success) {
36+
if (!results.success) {
3937
return {
4038
success: false,
4139
error: new ParseError('Failed to parse Lambda function URL body', {
42-
cause: parsedBody.error,
40+
cause: results.error,
4341
}),
4442
originalEvent: data,
4543
};
4644
}
4745

48-
return parsedBody;
46+
return {
47+
success: true,
48+
data: results.data.body,
49+
};
4950
},
5051
};

Diff for: packages/parser/tests/events/lambdaFunctionUrlEventPathTrailingSlash.json renamed to packages/parser/tests/events/lambda/base.json

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"version": "2.0",
33
"routeKey": "$default",
4-
"rawPath": "/my/path/",
4+
"rawPath": "/",
55
"rawQueryString": "parameter1=value1&parameter1=value2&parameter2=value",
66
"cookies": ["cookie1", "cookie2"],
77
"headers": {
@@ -31,7 +31,7 @@
3131
"domainPrefix": "<url-id>",
3232
"http": {
3333
"method": "POST",
34-
"path": "/my/path",
34+
"path": "/",
3535
"protocol": "HTTP/1.1",
3636
"sourceIp": "123.123.123.123",
3737
"userAgent": "agent"
@@ -42,7 +42,7 @@
4242
"time": "12/Mar/2020:19:03:58 +0000",
4343
"timeEpoch": 1583348638390
4444
},
45-
"body": "Hello from client!",
45+
"body": null,
4646
"pathParameters": null,
4747
"isBase64Encoded": false,
4848
"stageVariables": null

Diff for: packages/parser/tests/events/lambda/invalid.json

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"version": "2.0",
3+
"routeKey": "$default",
4+
"rawQueryString": "parameter1=value1&parameter1=value2&parameter2=value",
5+
"cookies": ["cookie1", "cookie2"],
6+
"headers": {
7+
"header1": "value1",
8+
"header2": "value1,value2"
9+
},
10+
"queryStringParameters": {
11+
"parameter1": "value1,value2",
12+
"parameter2": "value"
13+
},
14+
"requestContext": {
15+
"accountId": "123456789012",
16+
"apiId": "<urlid>",
17+
"authentication": null,
18+
"authorizer": {
19+
"iam": {
20+
"accessKey": "AKIA...",
21+
"accountId": "111122223333",
22+
"callerId": "AIDA...",
23+
"cognitoIdentity": null,
24+
"principalOrgId": null,
25+
"userArn": "arn:aws:iam::111122223333:user/example-user",
26+
"userId": "AIDA..."
27+
}
28+
},
29+
"domainName": "<url-id>.lambda-url.us-west-2.on.aws",
30+
"domainPrefix": "<url-id>",
31+
"http": {
32+
"method": "POST",
33+
"path": "/",
34+
"protocol": "HTTP/1.1",
35+
"sourceIp": "123.123.123.123",
36+
"userAgent": "agent"
37+
},
38+
"requestId": "id",
39+
"routeKey": "$default",
40+
"stage": "$default",
41+
"time": "12/Mar/2020:19:03:58 +0000",
42+
"timeEpoch": 1583348638390
43+
},
44+
"body": null,
45+
"pathParameters": null,
46+
"isBase64Encoded": false,
47+
"stageVariables": null
48+
}

Diff for: packages/parser/tests/unit/envelopes/lambda.test.ts

+109-68
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,139 @@
1-
import { generateMock } from '@anatine/zod-mock';
2-
import type {
3-
APIGatewayProxyEventV2,
4-
LambdaFunctionURLEvent,
5-
} from 'aws-lambda';
61
import { describe, expect, it } from 'vitest';
7-
import { ZodError } from 'zod';
2+
import { ZodError, z } from 'zod';
83
import { ParseError } from '../../../src';
94
import { LambdaFunctionUrlEnvelope } from '../../../src/envelopes/index.js';
10-
import { TestEvents, TestSchema } from '../schema/utils.js';
5+
import { JSONStringified } from '../../../src/helpers';
6+
import type { LambdaFunctionUrlEvent } from '../../../src/types';
7+
import { getTestEvent, omit } from '../schema/utils.js';
118

12-
describe('Lambda Functions Url ', () => {
13-
describe('parse', () => {
14-
it('should parse custom schema in envelope', () => {
15-
const testEvent =
16-
TestEvents.lambdaFunctionUrlEvent as APIGatewayProxyEventV2;
17-
const data = generateMock(TestSchema);
9+
describe('Envelope: Lambda function URL', () => {
10+
const schema = z
11+
.object({
12+
message: z.string(),
13+
})
14+
.strict();
1815

19-
testEvent.body = JSON.stringify(data);
16+
const baseEvent = getTestEvent<LambdaFunctionUrlEvent>({
17+
eventsPath: 'lambda',
18+
filename: 'base',
19+
});
20+
21+
describe('Method: parse', () => {
22+
it('throws if the payload does not match the schema', () => {
23+
// Prepare
24+
const event = structuredClone(baseEvent);
2025

21-
expect(LambdaFunctionUrlEnvelope.parse(testEvent, TestSchema)).toEqual(
22-
data
26+
// Act & Assess
27+
expect(() => LambdaFunctionUrlEnvelope.parse(event, schema)).toThrow(
28+
expect.objectContaining({
29+
message: expect.stringContaining(
30+
'Failed to parse Lambda function URL body'
31+
),
32+
cause: expect.objectContaining({
33+
issues: [
34+
{
35+
code: 'invalid_type',
36+
expected: 'object',
37+
received: 'null',
38+
path: ['body'],
39+
message: 'Expected object, received null',
40+
},
41+
],
42+
}),
43+
})
2344
);
2445
});
2546

26-
it('should throw when no body provided', () => {
27-
const testEvent =
28-
TestEvents.lambdaFunctionUrlEvent as LambdaFunctionURLEvent;
29-
testEvent.body = undefined;
47+
it('parses a Lambda function URL event with plain text', () => {
48+
// Prepare
49+
const event = structuredClone(baseEvent);
50+
event.body = 'hello world';
3051

31-
expect(() =>
32-
LambdaFunctionUrlEnvelope.parse(testEvent, TestSchema)
33-
).toThrow();
52+
// Act
53+
const result = LambdaFunctionUrlEnvelope.parse(event, z.string());
54+
55+
// Assess
56+
expect(result).toEqual('hello world');
3457
});
3558

36-
it('should throw when envelope is not valid', () => {
37-
expect(() =>
38-
LambdaFunctionUrlEnvelope.parse({ foo: 'bar' }, TestSchema)
39-
).toThrow();
59+
it('parses a Lambda function URL event with JSON-stringified body', () => {
60+
// Prepare
61+
const event = structuredClone(baseEvent);
62+
event.body = JSON.stringify({ message: 'hello world' });
63+
64+
// Act
65+
const result = LambdaFunctionUrlEnvelope.parse(
66+
event,
67+
JSONStringified(schema)
68+
);
69+
70+
// Assess
71+
expect(result).toEqual({ message: 'hello world' });
4072
});
4173

42-
it('should throw when body does not match schema', () => {
43-
const testEvent =
44-
TestEvents.lambdaFunctionUrlEvent as APIGatewayProxyEventV2;
45-
testEvent.body = JSON.stringify({ foo: 'bar' });
74+
it('parses a Lambda function URL event with binary body', () => {
75+
// Prepare
76+
const event = structuredClone(baseEvent);
77+
event.body = Buffer.from('hello world').toString('base64');
78+
event.headers['content-type'] = 'application/octet-stream';
79+
event.isBase64Encoded = true;
80+
81+
// Act
82+
const result = LambdaFunctionUrlEnvelope.parse(event, z.string());
4683

47-
expect(() =>
48-
LambdaFunctionUrlEnvelope.parse(testEvent, TestSchema)
49-
).toThrow();
84+
// Assess
85+
expect(result).toEqual('aGVsbG8gd29ybGQ=');
5086
});
5187
});
52-
describe('safeParse', () => {
53-
it('should parse custom schema in envelope', () => {
54-
const testEvent =
55-
TestEvents.lambdaFunctionUrlEvent as APIGatewayProxyEventV2;
56-
const data = generateMock(TestSchema);
88+
describe('Method: safeParse', () => {
89+
it('parses Lambda function URL event', () => {
90+
// Prepare
91+
const event = structuredClone(baseEvent);
92+
event.body = JSON.stringify({ message: 'hello world' });
5793

58-
testEvent.body = JSON.stringify(data);
94+
// Act
95+
const result = LambdaFunctionUrlEnvelope.safeParse(
96+
event,
97+
JSONStringified(schema)
98+
);
5999

60-
expect(
61-
LambdaFunctionUrlEnvelope.safeParse(testEvent, TestSchema)
62-
).toEqual({
100+
// Assess
101+
expect(result).toEqual({
63102
success: true,
64-
data,
103+
data: { message: 'hello world' },
65104
});
66105
});
67106

68-
it('should return original event when envelope is not valid', () => {
69-
expect(
70-
LambdaFunctionUrlEnvelope.safeParse({ foo: 'bar' }, TestSchema)
71-
).toEqual({
72-
success: false,
73-
error: expect.any(ParseError),
74-
originalEvent: { foo: 'bar' },
75-
});
76-
});
107+
it('returns an error when the event is not valid', () => {
108+
// Prepare
109+
const event = omit(['rawPath'], structuredClone(baseEvent));
77110

78-
it('should return original event when body does not match schema', () => {
79-
const testEvent =
80-
TestEvents.lambdaFunctionUrlEvent as APIGatewayProxyEventV2;
81-
testEvent.body = JSON.stringify({ foo: 'bar' });
111+
// Act
112+
const result = LambdaFunctionUrlEnvelope.safeParse(event, schema);
82113

83-
const parseResult = LambdaFunctionUrlEnvelope.safeParse(
84-
testEvent,
85-
TestSchema
86-
);
87-
expect(parseResult).toEqual({
114+
// Assess
115+
expect(result).toEqual({
88116
success: false,
89-
error: expect.any(ParseError),
90-
originalEvent: testEvent,
117+
error: new ParseError('Failed to parse Lambda function URL body', {
118+
cause: new ZodError([
119+
{
120+
code: 'invalid_type',
121+
expected: 'string',
122+
received: 'undefined',
123+
path: ['rawPath'],
124+
message: 'Required',
125+
},
126+
{
127+
code: 'invalid_type',
128+
expected: 'object',
129+
received: 'null',
130+
path: ['body'],
131+
message: 'Expected object, received null',
132+
},
133+
]),
134+
}),
135+
originalEvent: event,
91136
});
92-
93-
if (!parseResult.success && parseResult.error) {
94-
expect(parseResult.error.cause).toBeInstanceOf(ZodError);
95-
}
96137
});
97138
});
98139
});

Diff for: packages/parser/tests/unit/schema/lambda.test.ts

+28-10
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,37 @@
11
import { describe, expect, it } from 'vitest';
22
import { LambdaFunctionUrlSchema } from '../../../src/schemas/';
3-
import { TestEvents } from './utils.js';
3+
import { getTestEvent } from './utils.js';
44

5-
describe('Lambda ', () => {
6-
it('should parse lambda event', () => {
7-
const lambdaFunctionUrlEvent = TestEvents.lambdaFunctionUrlEvent;
5+
describe('Schema: LambdaFunctionUrl', () => {
6+
const eventsPath = 'lambda';
87

9-
expect(LambdaFunctionUrlSchema.parse(lambdaFunctionUrlEvent)).toEqual(
10-
lambdaFunctionUrlEvent
11-
);
8+
it('throw when the event is invalid', () => {
9+
// Prepare
10+
const event = getTestEvent({ eventsPath, filename: 'invalid' });
11+
12+
// Act & Assess
13+
expect(() => LambdaFunctionUrlSchema.parse(event)).toThrow();
14+
});
15+
16+
it('parses a valid event', () => {
17+
// Prepare
18+
const event = getTestEvent({ eventsPath, filename: 'get-request' });
19+
20+
// Act
21+
const parsedEvent = LambdaFunctionUrlSchema.parse(event);
22+
23+
// Assess
24+
expect(parsedEvent).toEqual(event);
1225
});
1326

14-
it('should parse url IAM event', () => {
15-
const urlIAMEvent = TestEvents.lambdaFunctionUrlIAMEvent;
27+
it('parses iam event', () => {
28+
// Prepare
29+
const event = getTestEvent({ eventsPath, filename: 'iam-auth' });
30+
31+
// Act
32+
const parsedEvent = LambdaFunctionUrlSchema.parse(event);
1633

17-
expect(LambdaFunctionUrlSchema.parse(urlIAMEvent)).toEqual(urlIAMEvent);
34+
//
35+
expect(parsedEvent).toEqual(event);
1836
});
1937
});

0 commit comments

Comments
 (0)