Skip to content

fix(parser): API Gateway Envelopes handle non-JSON #3511

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 20 additions & 18 deletions packages/parser/src/envelopes/apigw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ZodSchema, z } from 'zod';
import { ParseError } from '../errors.js';
import { APIGatewayProxyEventSchema } from '../schemas/apigw.js';
import type { ParsedResult } from '../types/parser.js';
import { Envelope, envelopeDiscriminator } from './envelope.js';
import { envelopeDiscriminator } from './envelope.js';

/**
* API Gateway envelope to extract data within body key
Expand All @@ -14,36 +14,38 @@ export const ApiGatewayEnvelope = {
*/
[envelopeDiscriminator]: 'object' as const,
parse<T extends ZodSchema>(data: unknown, schema: T): z.infer<T> {
return Envelope.parse(APIGatewayProxyEventSchema.parse(data).body, schema);
try {
return APIGatewayProxyEventSchema.extend({
body: schema,
}).parse(data).body;
} catch (error) {
throw new ParseError('Failed to parse API Gateway body', {
cause: error as Error,
});
}
},

safeParse<T extends ZodSchema>(
data: unknown,
schema: T
): ParsedResult<unknown, z.infer<T>> {
const parsedEnvelope = APIGatewayProxyEventSchema.safeParse(data);
if (!parsedEnvelope.success) {
return {
success: false,
error: new ParseError('Failed to parse ApiGatewayEnvelope', {
cause: parsedEnvelope.error,
}),
originalEvent: data,
};
}

const parsedBody = Envelope.safeParse(parsedEnvelope.data.body, schema);
const result = APIGatewayProxyEventSchema.extend({
body: schema,
}).safeParse(data);

if (!parsedBody.success) {
if (!result.success) {
return {
success: false,
error: new ParseError('Failed to parse ApiGatewayEnvelope body', {
cause: parsedBody.error,
error: new ParseError('Failed to parse API Gateway body', {
cause: result.error,
}),
originalEvent: data,
};
}

return parsedBody;
return {
success: true,
data: result.data.body,
};
},
};
42 changes: 20 additions & 22 deletions packages/parser/src/envelopes/apigwv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ZodSchema, z } from 'zod';
import { ParseError } from '../errors.js';
import { APIGatewayProxyEventV2Schema } from '../schemas/apigwv2.js';
import type { ParsedResult } from '../types/index.js';
import { Envelope, envelopeDiscriminator } from './envelope.js';
import { envelopeDiscriminator } from './envelope.js';

/**
* API Gateway V2 envelope to extract data within body key
Expand All @@ -14,40 +14,38 @@ export const ApiGatewayV2Envelope = {
*/
[envelopeDiscriminator]: 'object' as const,
parse<T extends ZodSchema>(data: unknown, schema: T): z.infer<T> {
return Envelope.parse(
APIGatewayProxyEventV2Schema.parse(data).body,
schema
);
try {
return APIGatewayProxyEventV2Schema.extend({
body: schema,
}).parse(data).body;
} catch (error) {
throw new ParseError('Failed to parse API Gateway HTTP body', {
cause: error as Error,
});
}
},

safeParse<T extends ZodSchema>(
data: unknown,
schema: T
): ParsedResult<unknown, z.infer<T>> {
const parsedEnvelope = APIGatewayProxyEventV2Schema.safeParse(data);
if (!parsedEnvelope.success) {
return {
success: false,
error: new ParseError('Failed to parse API Gateway V2 envelope', {
cause: parsedEnvelope.error,
}),
originalEvent: data,
};
}

const parsedBody = Envelope.safeParse(parsedEnvelope.data.body, schema);
const result = APIGatewayProxyEventV2Schema.extend({
body: schema,
}).safeParse(data);

if (!parsedBody.success) {
if (!result.success) {
return {
success: false,
error: new ParseError('Failed to parse API Gateway V2 envelope body', {
cause: parsedBody.error,
error: new ParseError('Failed to parse API Gateway HTTP body', {
cause: result.error,
}),
originalEvent: data,
};
}

// use type assertion to avoid type check, we know it's success here
return parsedBody;
return {
success: true,
data: result.data.body,
};
},
};
7 changes: 6 additions & 1 deletion packages/parser/src/envelopes/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ZodSchema, z } from 'zod';
import { ParseError } from '../errors.js';
import type { ParsedResult } from '../types/parser.js';

/* v8 ignore start */
const Envelope = {
/**
* Abstract function to parse the content of the envelope using provided schema.
Expand Down Expand Up @@ -35,7 +36,10 @@ const Envelope = {
* @param input
* @param schema
*/
safeParse<T extends ZodSchema>(input: unknown, schema: T): ParsedResult<unknown, z.infer<T>> {
safeParse<T extends ZodSchema>(
input: unknown,
schema: T
): ParsedResult<unknown, z.infer<T>> {
try {
if (typeof input !== 'object' && typeof input !== 'string') {
return {
Expand Down Expand Up @@ -75,3 +79,4 @@ const Envelope = {
const envelopeDiscriminator = Symbol.for('returnType');

export { Envelope, envelopeDiscriminator };
/* v8 ignore stop */
155 changes: 83 additions & 72 deletions packages/parser/tests/unit/envelopes/apigw.test.ts
Original file line number Diff line number Diff line change
@@ -1,123 +1,134 @@
import { describe, expect, it } from 'vitest';
import { ZodError } from 'zod';
import { ZodError, z } from 'zod';
import { ApiGatewayEnvelope } from '../../../src/envelopes/index.js';
import { ParseError } from '../../../src/errors.js';
import { JSONStringified } from '../../../src/helpers.js';
import type { APIGatewayProxyEvent } from '../../../src/types/schema.js';
import { TestSchema, getTestEvent } from '../schema/utils.js';

describe('API Gateway REST Envelope', () => {
const eventsPath = 'apigw-rest';
const eventPrototype = getTestEvent<APIGatewayProxyEvent>({
eventsPath,
import { getTestEvent, omit } from '../schema/utils.js';

describe('Envelope: API Gateway REST', () => {
const schema = z
.object({
message: z.string(),
})
.strict();
const baseEvent = getTestEvent<APIGatewayProxyEvent>({
eventsPath: 'apigw-rest',
filename: 'no-auth',
});

describe('Method: parse', () => {
it('should throw if the payload does not match the schema', () => {
// Prepare
const event = { ...eventPrototype };
event.body = JSON.stringify({ name: 'foo' });

// Act & Assess
expect(() => ApiGatewayEnvelope.parse(event, TestSchema)).toThrow(
ParseError
);
});

it('should throw if the body is null', () => {
it('throws if the payload does not match the schema', () => {
// Prepare
const event = { ...eventPrototype };
event.body = null;
const event = structuredClone(baseEvent);

// Act & Assess
expect(() => ApiGatewayEnvelope.parse(event, TestSchema)).toThrow(
ParseError
expect(() => ApiGatewayEnvelope.parse(event, schema)).toThrow(
expect.objectContaining({
message: expect.stringContaining('Failed to parse API Gateway body'),
cause: expect.objectContaining({
issues: [
{
code: 'invalid_type',
expected: 'object',
received: 'null',
path: ['body'],
message: 'Expected object, received null',
},
],
}),
})
);
});

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

// Act
const parsedEvent = ApiGatewayEnvelope.parse(event, TestSchema);
const result = ApiGatewayEnvelope.parse(event, z.string());

// Assess
expect(parsedEvent).toEqual(payload);
expect(result).toEqual('hello world');
});
});

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

// Act
const parseResult = ApiGatewayEnvelope.safeParse(event, TestSchema);
const result = ApiGatewayEnvelope.parse(event, JSONStringified(schema));

// Assess
expect(parseResult).toEqual({
success: false,
error: expect.any(ParseError),
originalEvent: event,
});

if (!parseResult.success && parseResult.error) {
expect(parseResult.error.cause).toBeInstanceOf(ZodError);
}
expect(result).toStrictEqual({ message: 'hello world' });
});

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

// Act
const parseResult = ApiGatewayEnvelope.safeParse(event, TestSchema);
const result = ApiGatewayEnvelope.parse(event, z.string());

// Assess
expect(parseResult).toEqual({
success: false,
error: expect.any(ParseError),
originalEvent: event,
});

if (!parseResult.success && parseResult.error) {
expect(parseResult.error.cause).toBeInstanceOf(ZodError);
}
expect(result).toEqual('aGVsbG8gd29ybGQ=');
});
});

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

// Act
const parseResult = ApiGatewayEnvelope.safeParse(event, TestSchema);
const result = ApiGatewayEnvelope.safeParse(
event,
JSONStringified(schema)
);

// Assess
expect(parseResult).toEqual({
success: false,
error: expect.any(ParseError),
originalEvent: event,
expect(result).toEqual({
success: true,
data: { message: 'hello world' },
});
});

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

// Act
const parsedEvent = ApiGatewayEnvelope.safeParse(event, TestSchema);
const result = ApiGatewayEnvelope.safeParse(event, schema);

// Assess
expect(parsedEvent).toEqual({
success: true,
data: payload,
expect(result).toEqual({
success: false,
error: new ParseError('Failed to parse API Gateway body', {
cause: new ZodError([
{
code: 'invalid_type',
expected: 'string',
received: 'undefined',
path: ['path'],
message: 'Required',
},
{
code: 'invalid_type',
expected: 'object',
received: 'null',
path: ['body'],
message: 'Expected object, received null',
},
]),
}),
originalEvent: event,
});
});
});
Expand Down
Loading
Loading