Skip to content

Commit a4846af

Browse files
authored
fix(parser): API Gateway Envelopes handle non-JSON (#3511)
1 parent 781a14e commit a4846af

File tree

7 files changed

+234
-202
lines changed

7 files changed

+234
-202
lines changed

packages/parser/src/envelopes/apigw.ts

Lines changed: 20 additions & 18 deletions
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 { APIGatewayProxyEventSchema } from '../schemas/apigw.js';
44
import type { ParsedResult } from '../types/parser.js';
5-
import { Envelope, envelopeDiscriminator } from './envelope.js';
5+
import { envelopeDiscriminator } from './envelope.js';
66

77
/**
88
* API Gateway envelope to extract data within body key
@@ -14,36 +14,38 @@ export const ApiGatewayEnvelope = {
1414
*/
1515
[envelopeDiscriminator]: 'object' as const,
1616
parse<T extends ZodSchema>(data: unknown, schema: T): z.infer<T> {
17-
return Envelope.parse(APIGatewayProxyEventSchema.parse(data).body, schema);
17+
try {
18+
return APIGatewayProxyEventSchema.extend({
19+
body: schema,
20+
}).parse(data).body;
21+
} catch (error) {
22+
throw new ParseError('Failed to parse API Gateway body', {
23+
cause: error as Error,
24+
});
25+
}
1826
},
1927

2028
safeParse<T extends ZodSchema>(
2129
data: unknown,
2230
schema: T
2331
): ParsedResult<unknown, z.infer<T>> {
24-
const parsedEnvelope = APIGatewayProxyEventSchema.safeParse(data);
25-
if (!parsedEnvelope.success) {
26-
return {
27-
success: false,
28-
error: new ParseError('Failed to parse ApiGatewayEnvelope', {
29-
cause: parsedEnvelope.error,
30-
}),
31-
originalEvent: data,
32-
};
33-
}
34-
35-
const parsedBody = Envelope.safeParse(parsedEnvelope.data.body, schema);
32+
const result = APIGatewayProxyEventSchema.extend({
33+
body: schema,
34+
}).safeParse(data);
3635

37-
if (!parsedBody.success) {
36+
if (!result.success) {
3837
return {
3938
success: false,
40-
error: new ParseError('Failed to parse ApiGatewayEnvelope body', {
41-
cause: parsedBody.error,
39+
error: new ParseError('Failed to parse API Gateway body', {
40+
cause: result.error,
4241
}),
4342
originalEvent: data,
4443
};
4544
}
4645

47-
return parsedBody;
46+
return {
47+
success: true,
48+
data: result.data.body,
49+
};
4850
},
4951
};

packages/parser/src/envelopes/apigwv2.ts

Lines changed: 20 additions & 22 deletions
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 { APIGatewayProxyEventV2Schema } from '../schemas/apigwv2.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
* API Gateway V2 envelope to extract data within body key
@@ -14,40 +14,38 @@ export const ApiGatewayV2Envelope = {
1414
*/
1515
[envelopeDiscriminator]: 'object' as const,
1616
parse<T extends ZodSchema>(data: unknown, schema: T): z.infer<T> {
17-
return Envelope.parse(
18-
APIGatewayProxyEventV2Schema.parse(data).body,
19-
schema
20-
);
17+
try {
18+
return APIGatewayProxyEventV2Schema.extend({
19+
body: schema,
20+
}).parse(data).body;
21+
} catch (error) {
22+
throw new ParseError('Failed to parse API Gateway HTTP body', {
23+
cause: error as Error,
24+
});
25+
}
2126
},
2227

2328
safeParse<T extends ZodSchema>(
2429
data: unknown,
2530
schema: T
2631
): ParsedResult<unknown, z.infer<T>> {
27-
const parsedEnvelope = APIGatewayProxyEventV2Schema.safeParse(data);
28-
if (!parsedEnvelope.success) {
29-
return {
30-
success: false,
31-
error: new ParseError('Failed to parse API Gateway V2 envelope', {
32-
cause: parsedEnvelope.error,
33-
}),
34-
originalEvent: data,
35-
};
36-
}
37-
38-
const parsedBody = Envelope.safeParse(parsedEnvelope.data.body, schema);
32+
const result = APIGatewayProxyEventV2Schema.extend({
33+
body: schema,
34+
}).safeParse(data);
3935

40-
if (!parsedBody.success) {
36+
if (!result.success) {
4137
return {
4238
success: false,
43-
error: new ParseError('Failed to parse API Gateway V2 envelope body', {
44-
cause: parsedBody.error,
39+
error: new ParseError('Failed to parse API Gateway HTTP body', {
40+
cause: result.error,
4541
}),
4642
originalEvent: data,
4743
};
4844
}
4945

50-
// use type assertion to avoid type check, we know it's success here
51-
return parsedBody;
46+
return {
47+
success: true,
48+
data: result.data.body,
49+
};
5250
},
5351
};

packages/parser/src/envelopes/envelope.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ZodSchema, z } from 'zod';
22
import { ParseError } from '../errors.js';
33
import type { ParsedResult } from '../types/parser.js';
44

5+
/* v8 ignore start */
56
const Envelope = {
67
/**
78
* Abstract function to parse the content of the envelope using provided schema.
@@ -35,7 +36,10 @@ const Envelope = {
3536
* @param input
3637
* @param schema
3738
*/
38-
safeParse<T extends ZodSchema>(input: unknown, schema: T): ParsedResult<unknown, z.infer<T>> {
39+
safeParse<T extends ZodSchema>(
40+
input: unknown,
41+
schema: T
42+
): ParsedResult<unknown, z.infer<T>> {
3943
try {
4044
if (typeof input !== 'object' && typeof input !== 'string') {
4145
return {
@@ -75,3 +79,4 @@ const Envelope = {
7579
const envelopeDiscriminator = Symbol.for('returnType');
7680

7781
export { Envelope, envelopeDiscriminator };
82+
/* v8 ignore stop */

packages/parser/tests/unit/envelopes/apigw.test.ts

Lines changed: 83 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,123 +1,134 @@
11
import { describe, expect, it } from 'vitest';
2-
import { ZodError } from 'zod';
2+
import { ZodError, z } from 'zod';
33
import { ApiGatewayEnvelope } from '../../../src/envelopes/index.js';
44
import { ParseError } from '../../../src/errors.js';
5+
import { JSONStringified } from '../../../src/helpers.js';
56
import type { APIGatewayProxyEvent } from '../../../src/types/schema.js';
6-
import { TestSchema, getTestEvent } from '../schema/utils.js';
7-
8-
describe('API Gateway REST Envelope', () => {
9-
const eventsPath = 'apigw-rest';
10-
const eventPrototype = getTestEvent<APIGatewayProxyEvent>({
11-
eventsPath,
7+
import { getTestEvent, omit } from '../schema/utils.js';
8+
9+
describe('Envelope: API Gateway REST', () => {
10+
const schema = z
11+
.object({
12+
message: z.string(),
13+
})
14+
.strict();
15+
const baseEvent = getTestEvent<APIGatewayProxyEvent>({
16+
eventsPath: 'apigw-rest',
1217
filename: 'no-auth',
1318
});
1419

1520
describe('Method: parse', () => {
16-
it('should throw if the payload does not match the schema', () => {
17-
// Prepare
18-
const event = { ...eventPrototype };
19-
event.body = JSON.stringify({ name: 'foo' });
20-
21-
// Act & Assess
22-
expect(() => ApiGatewayEnvelope.parse(event, TestSchema)).toThrow(
23-
ParseError
24-
);
25-
});
26-
27-
it('should throw if the body is null', () => {
21+
it('throws if the payload does not match the schema', () => {
2822
// Prepare
29-
const event = { ...eventPrototype };
30-
event.body = null;
23+
const event = structuredClone(baseEvent);
3124

3225
// Act & Assess
33-
expect(() => ApiGatewayEnvelope.parse(event, TestSchema)).toThrow(
34-
ParseError
26+
expect(() => ApiGatewayEnvelope.parse(event, schema)).toThrow(
27+
expect.objectContaining({
28+
message: expect.stringContaining('Failed to parse API Gateway body'),
29+
cause: expect.objectContaining({
30+
issues: [
31+
{
32+
code: 'invalid_type',
33+
expected: 'object',
34+
received: 'null',
35+
path: ['body'],
36+
message: 'Expected object, received null',
37+
},
38+
],
39+
}),
40+
})
3541
);
3642
});
3743

38-
it('should parse and return the inner schema in an envelope', () => {
44+
it('parses an API Gateway REST event with plain text', () => {
3945
// Prepare
40-
const event = { ...eventPrototype };
41-
const payload = { name: 'foo', age: 42 };
42-
event.body = JSON.stringify(payload);
46+
const event = structuredClone(baseEvent);
47+
event.body = 'hello world';
4348

4449
// Act
45-
const parsedEvent = ApiGatewayEnvelope.parse(event, TestSchema);
50+
const result = ApiGatewayEnvelope.parse(event, z.string());
4651

4752
// Assess
48-
expect(parsedEvent).toEqual(payload);
53+
expect(result).toEqual('hello world');
4954
});
50-
});
5155

52-
describe('Method: safeParse', () => {
53-
it('should not throw if the payload does not match the schema', () => {
56+
it('parses an API Gateway REST event with JSON-stringified body', () => {
5457
// Prepare
55-
const event = { ...eventPrototype };
56-
event.body = JSON.stringify({ name: 'foo' });
58+
const event = structuredClone(baseEvent);
59+
event.body = JSON.stringify({ message: 'hello world' });
5760

5861
// Act
59-
const parseResult = ApiGatewayEnvelope.safeParse(event, TestSchema);
62+
const result = ApiGatewayEnvelope.parse(event, JSONStringified(schema));
6063

6164
// Assess
62-
expect(parseResult).toEqual({
63-
success: false,
64-
error: expect.any(ParseError),
65-
originalEvent: event,
66-
});
67-
68-
if (!parseResult.success && parseResult.error) {
69-
expect(parseResult.error.cause).toBeInstanceOf(ZodError);
70-
}
65+
expect(result).toStrictEqual({ message: 'hello world' });
7166
});
7267

73-
it('should not throw if the body is null', () => {
68+
it('parses an API Gateway REST event with binary body', () => {
7469
// Prepare
75-
const event = { ...eventPrototype };
76-
event.body = null;
70+
const event = structuredClone(baseEvent);
71+
event.body = 'aGVsbG8gd29ybGQ='; // base64 encoded 'hello world'
72+
// @ts-expect-error - we know the headers exist
73+
event.headers['content-type'] = 'application/octet-stream';
74+
event.isBase64Encoded = true;
7775

7876
// Act
79-
const parseResult = ApiGatewayEnvelope.safeParse(event, TestSchema);
77+
const result = ApiGatewayEnvelope.parse(event, z.string());
8078

8179
// Assess
82-
expect(parseResult).toEqual({
83-
success: false,
84-
error: expect.any(ParseError),
85-
originalEvent: event,
86-
});
87-
88-
if (!parseResult.success && parseResult.error) {
89-
expect(parseResult.error.cause).toBeInstanceOf(ZodError);
90-
}
80+
expect(result).toEqual('aGVsbG8gd29ybGQ=');
9181
});
82+
});
9283

93-
it('should not throw if the event is invalid', () => {
84+
describe('Method: safeParse', () => {
85+
it('parses an API Gateway REST event', () => {
9486
// Prepare
95-
const event = getTestEvent({ eventsPath, filename: 'invalid' });
87+
const event = structuredClone(baseEvent);
88+
event.body = JSON.stringify({ message: 'hello world' });
9689

9790
// Act
98-
const parseResult = ApiGatewayEnvelope.safeParse(event, TestSchema);
91+
const result = ApiGatewayEnvelope.safeParse(
92+
event,
93+
JSONStringified(schema)
94+
);
9995

10096
// Assess
101-
expect(parseResult).toEqual({
102-
success: false,
103-
error: expect.any(ParseError),
104-
originalEvent: event,
97+
expect(result).toEqual({
98+
success: true,
99+
data: { message: 'hello world' },
105100
});
106101
});
107102

108-
it('should parse and return the inner schema in an envelope', () => {
103+
it('returns an error if the event is not a valid API Gateway REST event', () => {
109104
// Prepare
110-
const event = { ...eventPrototype };
111-
const payload = { name: 'foo', age: 42 };
112-
event.body = JSON.stringify(payload);
105+
const event = omit(['path'], structuredClone(baseEvent));
113106

114107
// Act
115-
const parsedEvent = ApiGatewayEnvelope.safeParse(event, TestSchema);
108+
const result = ApiGatewayEnvelope.safeParse(event, schema);
116109

117110
// Assess
118-
expect(parsedEvent).toEqual({
119-
success: true,
120-
data: payload,
111+
expect(result).toEqual({
112+
success: false,
113+
error: new ParseError('Failed to parse API Gateway body', {
114+
cause: new ZodError([
115+
{
116+
code: 'invalid_type',
117+
expected: 'string',
118+
received: 'undefined',
119+
path: ['path'],
120+
message: 'Required',
121+
},
122+
{
123+
code: 'invalid_type',
124+
expected: 'object',
125+
received: 'null',
126+
path: ['body'],
127+
message: 'Expected object, received null',
128+
},
129+
]),
130+
}),
131+
originalEvent: event,
121132
});
122133
});
123134
});

0 commit comments

Comments
 (0)