diff --git a/packages/parser/src/envelopes/cloudwatch.ts b/packages/parser/src/envelopes/cloudwatch.ts index f3d090e31c..0b8df691d6 100644 --- a/packages/parser/src/envelopes/cloudwatch.ts +++ b/packages/parser/src/envelopes/cloudwatch.ts @@ -2,7 +2,7 @@ import { ZodError, type ZodType, type z } from 'zod'; import { ParseError } from '../errors.js'; import { CloudWatchLogsSchema } from '../schemas/index.js'; import type { ParsedResult } from '../types/index.js'; -import { envelopeDiscriminator, prefixIssuePaths } from './envelope.js'; +import { envelopeDiscriminator, parseOrThrow } from './envelope.js'; /** * CloudWatch Envelope to extract messages from the `awslogs.data.logEvents` key. @@ -14,26 +14,20 @@ export const CloudWatchEnvelope = { */ [envelopeDiscriminator]: 'array' as const, parse(data: unknown, schema: ZodType): T[] { - const parsedEnvelope = CloudWatchLogsSchema.parse(data); + const parsedEnvelope = parseOrThrow( + CloudWatchLogsSchema, + data, + 'Failed to parse CloudWatch Log envelope' + ); - return parsedEnvelope.awslogs.data.logEvents.map((record, index) => { - try { - return schema.parse(record.message); - } catch (error) { - throw new ParseError( - `Failed to parse CloudWatch log event at index ${index}`, - { - cause: prefixIssuePaths(error, [ - 'awslogs', - 'data', - 'logEvents', - index, - 'message', - ]), - } - ); - } - }); + return parsedEnvelope.awslogs.data.logEvents.map((record, index) => + parseOrThrow( + schema, + record.message, + `Failed to parse CloudWatch log event at index ${index}`, + ['awslogs', 'data', 'logEvents', index, 'message'] + ) + ); }, safeParse(data: unknown, schema: ZodType): ParsedResult { diff --git a/packages/parser/src/envelopes/dynamodb.ts b/packages/parser/src/envelopes/dynamodb.ts index a021fbc6de..7f53d043a7 100644 --- a/packages/parser/src/envelopes/dynamodb.ts +++ b/packages/parser/src/envelopes/dynamodb.ts @@ -3,7 +3,7 @@ import { ParseError } from '../errors.js'; import { DynamoDBStreamSchema } from '../schemas/index.js'; import type { DynamoDBStreamEnvelopeResponse } from '../types/envelope.js'; import type { ParsedResult } from '../types/index.js'; -import { envelopeDiscriminator, prefixIssuePaths } from './envelope.js'; +import { envelopeDiscriminator, parseOrThrow } from './envelope.js'; /** * DynamoDB Stream Envelope to extract data within NewImage/OldImage @@ -21,29 +21,25 @@ export const DynamoDBStreamEnvelope = { data: unknown, schema: ZodType ): DynamoDBStreamEnvelopeResponse[] { - const parsedEnvelope = DynamoDBStreamSchema.parse(data); + const parsedEnvelope = parseOrThrow( + DynamoDBStreamSchema, + data, + 'Failed to parse DynamoDB Stream envelope' + ); const processImage = ( image: unknown, imageType: 'NewImage' | 'OldImage', recordIndex: number - ) => { - try { - return image ? schema.parse(image) : undefined; - } catch (error) { - throw new ParseError( - `Failed to parse DynamoDB record at index ${recordIndex}`, - { - cause: prefixIssuePaths(error, [ - 'Records', - recordIndex, - 'dynamodb', - imageType, - ]), - } - ); - } - }; + ) => + image + ? parseOrThrow( + schema, + image, + `Failed to parse DynamoDB record at index ${recordIndex}`, + ['Records', recordIndex, 'dynamodb', imageType] + ) + : undefined; return parsedEnvelope.Records.map((record, index) => ({ NewImage: processImage(record.dynamodb.NewImage, 'NewImage', index), diff --git a/packages/parser/src/envelopes/envelope.ts b/packages/parser/src/envelopes/envelope.ts index f8020f4fa2..e28a22cd34 100644 --- a/packages/parser/src/envelopes/envelope.ts +++ b/packages/parser/src/envelopes/envelope.ts @@ -1,4 +1,5 @@ -import { ZodError } from 'zod'; +import { ZodError, type ZodType } from 'zod'; +import { ParseError } from '../errors.js'; /** * This is a discriminator to differentiate whether an envelope returns an array or an object @@ -24,4 +25,30 @@ const prefixIssuePaths = (error: unknown, path: PropertyKey[]): unknown => ) : error; -export { envelopeDiscriminator, prefixIssuePaths }; +/** + * Parses the data with the schema, and throws a `ParseError` with the given message when parsing fails. + * + * The original error is kept as the cause. When a path is given, the issue paths of a `ZodError` are + * prefixed with it through {@link prefixIssuePaths | `prefixIssuePaths`}. + * + * @param schema - the schema to parse the data with + * @param data - the data to parse + * @param message - the message of the `ParseError` thrown when parsing fails + * @param path - the path of the data inside the event + */ +const parseOrThrow = ( + schema: ZodType, + data: unknown, + message: string, + path?: PropertyKey[] +): T => { + try { + return schema.parse(data); + } catch (error) { + throw new ParseError(message, { + cause: path === undefined ? error : prefixIssuePaths(error, path), + }); + } +}; + +export { envelopeDiscriminator, parseOrThrow, prefixIssuePaths }; diff --git a/packages/parser/src/envelopes/kafka.ts b/packages/parser/src/envelopes/kafka.ts index 055d8091c1..aca786f894 100644 --- a/packages/parser/src/envelopes/kafka.ts +++ b/packages/parser/src/envelopes/kafka.ts @@ -4,8 +4,12 @@ import { KafkaMskEventSchema, KafkaSelfManagedEventSchema, } from '../schemas/kafka.js'; -import type { KafkaMskEvent, ParsedResult } from '../types/index.js'; -import { envelopeDiscriminator } from './envelope.js'; +import type { + KafkaMskEvent, + KafkaSelfManagedEvent, + ParsedResult, +} from '../types/index.js'; +import { envelopeDiscriminator, parseOrThrow } from './envelope.js'; /** * Get the event source from the data. @@ -14,20 +18,17 @@ import { envelopeDiscriminator } from './envelope.js'; * * @param data - The data to extract the event source from */ -const extractEventSource = ( - data: unknown -): 'aws:kafka' | 'SelfManagedKafka' => { - const verifiedData = z - .object({ +const extractEventSource = (data: unknown): 'aws:kafka' | 'SelfManagedKafka' => + parseOrThrow( + z.object({ eventSource: z.union([ z.literal('aws:kafka'), z.literal('SelfManagedKafka'), ]), - }) - .parse(data); - - return verifiedData.eventSource; -}; + }), + data, + 'Failed to parse Kafka envelope' + ).eventSource; /** * Kafka event envelope to extract data within body key @@ -44,17 +45,27 @@ export const KafkaEnvelope = { */ [envelopeDiscriminator]: 'array' as const, parse(data: unknown, schema: ZodType): z.infer>[] { - const eventSource = extractEventSource(data); - - const parsedEnvelope = - eventSource === 'aws:kafka' - ? KafkaMskEventSchema.parse(data) - : KafkaSelfManagedEventSchema.parse(data); + const parsedEnvelope = parseOrThrow( + extractEventSource(data) === 'aws:kafka' + ? KafkaMskEventSchema + : KafkaSelfManagedEventSchema, + data, + 'Failed to parse Kafka envelope' + ); const values: z.infer>[] = []; - for (const topicRecord of Object.values(parsedEnvelope.records)) { - for (const record of topicRecord) { - values.push(schema.parse(record.value)); + for (const [topicKey, topicRecord] of Object.entries( + parsedEnvelope.records + )) { + for (const [index, record] of topicRecord.entries()) { + values.push( + parseOrThrow( + schema, + record.value, + `Failed to parse Kafka record at index ${index} of ${topicKey}`, + ['records', topicKey, index, 'value'] + ) + ); } } diff --git a/packages/parser/src/envelopes/kinesis-firehose.ts b/packages/parser/src/envelopes/kinesis-firehose.ts index 849eab4fa3..c55abf0310 100644 --- a/packages/parser/src/envelopes/kinesis-firehose.ts +++ b/packages/parser/src/envelopes/kinesis-firehose.ts @@ -2,7 +2,7 @@ import { ZodError, type ZodType, type z } from 'zod'; import { ParseError } from '../errors.js'; import { KinesisFirehoseSchema } from '../schemas/index.js'; import type { ParsedResult } from '../types/index.js'; -import { envelopeDiscriminator, prefixIssuePaths } from './envelope.js'; +import { envelopeDiscriminator, parseOrThrow } from './envelope.js'; /** * Kinesis Firehose Envelope to extract array of Records @@ -23,29 +23,20 @@ export const KinesisFirehoseEnvelope = { */ [envelopeDiscriminator]: 'array' as const, parse(data: unknown, schema: ZodType): T[] { - let parsedEnvelope: z.infer; - try { - parsedEnvelope = KinesisFirehoseSchema.parse(data); - } catch (error) { - throw new ParseError('Failed to parse Kinesis Firehose envelope', { - cause: error as Error, - }); - } + const parsedEnvelope = parseOrThrow( + KinesisFirehoseSchema, + data, + 'Failed to parse Kinesis Firehose envelope' + ); - return parsedEnvelope.records.map((record, recordIndex) => { - let parsedRecord: T; - try { - parsedRecord = schema.parse(record.data); - } catch (error) { - throw new ParseError( - `Failed to parse Kinesis Firehose record at index ${recordIndex}`, - { - cause: prefixIssuePaths(error, ['records', recordIndex, 'data']), - } - ); - } - return parsedRecord; - }); + return parsedEnvelope.records.map((record, recordIndex) => + parseOrThrow( + schema, + record.data, + `Failed to parse Kinesis Firehose record at index ${recordIndex}`, + ['records', recordIndex, 'data'] + ) + ); }, safeParse(data: unknown, schema: ZodType): ParsedResult { diff --git a/packages/parser/src/envelopes/kinesis.ts b/packages/parser/src/envelopes/kinesis.ts index ee143b8e67..a97e9f6f1b 100644 --- a/packages/parser/src/envelopes/kinesis.ts +++ b/packages/parser/src/envelopes/kinesis.ts @@ -2,7 +2,7 @@ import { ZodError, type ZodType, type z } from 'zod'; import { ParseError } from '../errors.js'; import { KinesisDataStreamSchema } from '../schemas/kinesis.js'; import type { ParsedResult } from '../types/index.js'; -import { envelopeDiscriminator, prefixIssuePaths } from './envelope.js'; +import { envelopeDiscriminator, parseOrThrow } from './envelope.js'; /** * Kinesis Data Stream Envelope to extract array of Records @@ -21,34 +21,20 @@ export const KinesisEnvelope = { */ [envelopeDiscriminator]: 'array' as const, parse(data: unknown, schema: ZodType): T[] { - let parsedEnvelope: z.infer; - try { - parsedEnvelope = KinesisDataStreamSchema.parse(data); - } catch (error) { - throw new ParseError('Failed to parse Kinesis Data Stream envelope', { - cause: error as Error, - }); - } + const parsedEnvelope = parseOrThrow( + KinesisDataStreamSchema, + data, + 'Failed to parse Kinesis Data Stream envelope' + ); - return parsedEnvelope.Records.map((record, recordIndex) => { - let parsedRecord: T; - try { - parsedRecord = schema.parse(record.kinesis.data); - } catch (error) { - throw new ParseError( - `Failed to parse Kinesis Data Stream record at index ${recordIndex}`, - { - cause: prefixIssuePaths(error, [ - 'Records', - recordIndex, - 'kinesis', - 'data', - ]), - } - ); - } - return parsedRecord; - }); + return parsedEnvelope.Records.map((record, recordIndex) => + parseOrThrow( + schema, + record.kinesis.data, + `Failed to parse Kinesis Data Stream record at index ${recordIndex}`, + ['Records', recordIndex, 'kinesis', 'data'] + ) + ); }, safeParse(data: unknown, schema: ZodType): ParsedResult { diff --git a/packages/parser/src/envelopes/sns-sqs.ts b/packages/parser/src/envelopes/sns-sqs.ts index 5532a8918e..5befc75d24 100644 --- a/packages/parser/src/envelopes/sns-sqs.ts +++ b/packages/parser/src/envelopes/sns-sqs.ts @@ -3,7 +3,11 @@ import { ParseError } from '../errors.js'; import { SnsSqsNotificationSchema } from '../schemas/sns.js'; import { SqsSchema } from '../schemas/sqs.js'; import type { ParsedResult, SnsSqsNotification } from '../types/index.js'; -import { envelopeDiscriminator, prefixIssuePaths } from './envelope.js'; +import { + envelopeDiscriminator, + parseOrThrow, + prefixIssuePaths, +} from './envelope.js'; const createError = (index: number, issues: z.core.$ZodIssue[]) => ({ issues: issues.map((issue) => ({ @@ -57,14 +61,11 @@ export const SnsSqsEnvelope = { */ [envelopeDiscriminator]: 'array' as const, parse(data: unknown, schema: ZodType): T[] { - let parsedEnvelope: z.infer; - try { - parsedEnvelope = SqsSchema.parse(data); - } catch (error) { - throw new ParseError('Failed to parse SQS Envelope', { - cause: error as Error, - }); - } + const parsedEnvelope = parseOrThrow( + SqsSchema, + data, + 'Failed to parse SQS Envelope' + ); return parsedEnvelope.Records.map((record, recordIndex) => { let body: unknown; diff --git a/packages/parser/src/envelopes/sns.ts b/packages/parser/src/envelopes/sns.ts index c8183fa60f..ee4cdd752b 100644 --- a/packages/parser/src/envelopes/sns.ts +++ b/packages/parser/src/envelopes/sns.ts @@ -2,7 +2,7 @@ import { ZodError, type ZodType, type z } from 'zod'; import { ParseError } from '../errors.js'; import { SnsSchema } from '../schemas/sns.js'; import type { ParsedResult } from '../types/index.js'; -import { envelopeDiscriminator, prefixIssuePaths } from './envelope.js'; +import { envelopeDiscriminator, parseOrThrow } from './envelope.js'; /** * SNS Envelope to extract array of Records @@ -20,17 +20,20 @@ export const SnsEnvelope = { */ [envelopeDiscriminator]: 'array' as const, parse(data: unknown, schema: ZodType): T[] { - const parsedEnvelope = SnsSchema.parse(data); + const parsedEnvelope = parseOrThrow( + SnsSchema, + data, + 'Failed to parse SNS envelope' + ); - return parsedEnvelope.Records.map((record, index) => { - try { - return schema.parse(record.Sns.Message); - } catch (error) { - throw new ParseError(`Failed to parse SNS record at index ${index}`, { - cause: prefixIssuePaths(error, ['Records', index, 'Sns', 'Message']), - }); - } - }); + return parsedEnvelope.Records.map((record, index) => + parseOrThrow( + schema, + record.Sns.Message, + `Failed to parse SNS record at index ${index}`, + ['Records', index, 'Sns', 'Message'] + ) + ); }, safeParse(data: unknown, schema: ZodType): ParsedResult { diff --git a/packages/parser/src/envelopes/sqs.ts b/packages/parser/src/envelopes/sqs.ts index 781c6d3106..53d3fe3b59 100644 --- a/packages/parser/src/envelopes/sqs.ts +++ b/packages/parser/src/envelopes/sqs.ts @@ -2,7 +2,7 @@ import { ZodError, type ZodType, type z } from 'zod'; import { ParseError } from '../errors.js'; import { SqsSchema } from '../schemas/sqs.js'; import type { ParsedResult } from '../types/index.js'; -import { envelopeDiscriminator, prefixIssuePaths } from './envelope.js'; +import { envelopeDiscriminator, parseOrThrow } from './envelope.js'; /** * SQS Envelope to extract array of Records @@ -30,29 +30,20 @@ const SqsEnvelope = { */ [envelopeDiscriminator]: 'array' as const, parse(data: unknown, schema: ZodType): T[] { - let parsedEnvelope: z.infer; - try { - parsedEnvelope = SqsSchema.parse(data); - } catch (error) { - throw new ParseError('Failed to parse SQS Envelope', { - cause: error as Error, - }); - } + const parsedEnvelope = parseOrThrow( + SqsSchema, + data, + 'Failed to parse SQS Envelope' + ); - return parsedEnvelope.Records.map((record, recordIndex) => { - let parsedRecord: T; - try { - parsedRecord = schema.parse(record.body); - } catch (error) { - throw new ParseError( - `Failed to parse SQS Record at index ${recordIndex}`, - { - cause: prefixIssuePaths(error, ['Records', recordIndex, 'body']), - } - ); - } - return parsedRecord; - }); + return parsedEnvelope.Records.map((record, recordIndex) => + parseOrThrow( + schema, + record.body, + `Failed to parse SQS Record at index ${recordIndex}`, + ['Records', recordIndex, 'body'] + ) + ); }, safeParse(data: unknown, schema: ZodType): ParsedResult { diff --git a/packages/parser/tests/unit/envelopes/cloudwatch.test.ts b/packages/parser/tests/unit/envelopes/cloudwatch.test.ts index d4fa75fc03..1bf737598b 100644 --- a/packages/parser/tests/unit/envelopes/cloudwatch.test.ts +++ b/packages/parser/tests/unit/envelopes/cloudwatch.test.ts @@ -66,6 +66,29 @@ describe('Envelope: CloudWatch', () => { }; describe('Method: parse', () => { + it('throws a ParseError if the event is not a valid CloudWatch Logs event', () => { + // Prepare + const event = { awslogs: { data: 'invalid' } }; + + // Act & Assess + expect(() => CloudWatchEnvelope.parse(event, z.string())).toThrow( + expect.objectContaining({ + name: 'ParseError', + message: expect.stringContaining( + 'Failed to parse CloudWatch Log envelope' + ), + cause: expect.objectContaining({ + name: 'ZodError', + issues: [ + expect.objectContaining({ + path: ['awslogs', 'data'], + }), + ], + }), + }) + ); + }); + it('throws a ParseError with the original error as cause when a transform throws', () => { // Prepare const event = structuredClone(baseEvent); diff --git a/packages/parser/tests/unit/envelopes/dynamodb.test.ts b/packages/parser/tests/unit/envelopes/dynamodb.test.ts index ab10087eb2..80763c174f 100644 --- a/packages/parser/tests/unit/envelopes/dynamodb.test.ts +++ b/packages/parser/tests/unit/envelopes/dynamodb.test.ts @@ -18,6 +18,22 @@ describe('Envelope: DynamoDB Stream', () => { }); describe('Method: parse', () => { + it('throws a ParseError if the event is not a valid DynamoDB Stream event', () => { + // Prepare + const event = { Records: [{ foo: 'bar' }] }; + + // Act & Assess + expect(() => DynamoDBStreamEnvelope.parse(event, schema)).toThrow( + expect.objectContaining({ + name: 'ParseError', + message: expect.stringContaining( + 'Failed to parse DynamoDB Stream envelope' + ), + cause: expect.objectContaining({ name: 'ZodError' }), + }) + ); + }); + it('throws a ParseError with the original error as cause when a transform throws', () => { // Prepare const event = structuredClone(baseEvent); diff --git a/packages/parser/tests/unit/envelopes/kafka.test.ts b/packages/parser/tests/unit/envelopes/kafka.test.ts index 9961c8491a..6eb741f767 100644 --- a/packages/parser/tests/unit/envelopes/kafka.test.ts +++ b/packages/parser/tests/unit/envelopes/kafka.test.ts @@ -11,12 +11,81 @@ describe('Envelope: Kafka', () => { }); describe('Method: parse', () => { - it('throws if the payload of the value does not match the schema', () => { + it('throws a ParseError if the payload of the value does not match the schema', () => { // Prepare const event = structuredClone(baseEvent); // Act & Assess - expect(() => KafkaEnvelope.parse(event, z.number())).toThrow(); + expect(() => KafkaEnvelope.parse(event, z.number())).toThrow( + expect.objectContaining({ + name: 'ParseError', + message: expect.stringContaining( + 'Failed to parse Kafka record at index 0 of mytopic-0' + ), + cause: expect.objectContaining({ + issues: [ + expect.objectContaining({ + code: 'invalid_type', + path: ['records', 'mytopic-0', 0, 'value'], + }), + ], + }), + }) + ); + }); + + it('throws a ParseError with the original error as cause when a transform throws', () => { + // Prepare + const event = structuredClone(baseEvent); + const cause = new SyntaxError('boom'); + const throwingSchema = z.unknown().transform(() => { + throw cause; + }); + + // Act & Assess + expect(() => KafkaEnvelope.parse(event, throwingSchema)).toThrow( + expect.objectContaining({ name: 'ParseError', cause }) + ); + }); + + it('throws a ParseError if the event is not a valid Kafka event', () => { + // Prepare + const event = structuredClone(baseEvent); + // @ts-expect-error - Intentionally invalid event + event.records['mytopic-0'] = []; + + // Act & Assess + expect(() => KafkaEnvelope.parse(event, z.string())).toThrow( + expect.objectContaining({ + name: 'ParseError', + message: expect.stringContaining('Failed to parse Kafka envelope'), + cause: expect.objectContaining({ + issues: [ + expect.objectContaining({ + code: 'too_small', + path: ['records', 'mytopic-0'], + }), + ], + }), + }) + ); + }); + + it('throws a ParseError if the event source is unknown', () => { + // Prepare + const event = structuredClone(baseEvent); + event.eventSource = 'aws:sqs'; + + // Act & Assess + expect(() => KafkaEnvelope.parse(event, z.string())).toThrow( + expect.objectContaining({ + name: 'ParseError', + message: expect.stringContaining('Failed to parse Kafka envelope'), + cause: expect.objectContaining({ + issues: [expect.objectContaining({ path: ['eventSource'] })], + }), + }) + ); }); it('parses a Kafka event', () => { diff --git a/packages/parser/tests/unit/envelopes/sns.test.ts b/packages/parser/tests/unit/envelopes/sns.test.ts index 0711ec5f70..7787d1bc20 100644 --- a/packages/parser/tests/unit/envelopes/sns.test.ts +++ b/packages/parser/tests/unit/envelopes/sns.test.ts @@ -12,6 +12,30 @@ describe('Envelope: SnsEnvelope', () => { }); describe('Method: parse', () => { + it('throws a ParseError if the event is not a valid SNS event', () => { + // Prepare + const event = structuredClone(baseEvent); + // @ts-expect-error - force invalid event + event.Records[0].Sns = undefined; + + // Act & Assess + expect(() => SnsEnvelope.parse(event, z.string())).toThrow( + expect.objectContaining({ + name: 'ParseError', + message: expect.stringContaining('Failed to parse SNS envelope'), + cause: expect.objectContaining({ + name: 'ZodError', + issues: [ + expect.objectContaining({ + code: 'invalid_type', + path: ['Records', 0, 'Sns'], + }), + ], + }), + }) + ); + }); + it('throws a ParseError with the original error as cause when a transform throws', () => { // Prepare const event = structuredClone(baseEvent); diff --git a/packages/parser/tests/unit/parser.middy.test.ts b/packages/parser/tests/unit/parser.middy.test.ts index d050bab9e7..b11edc9b8d 100644 --- a/packages/parser/tests/unit/parser.middy.test.ts +++ b/packages/parser/tests/unit/parser.middy.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; import { EventBridgeEnvelope } from '../../src/envelopes/eventbridge.js'; import { EventBridgeWithMetadataEnvelope } from '../../src/envelopes/eventbridge-with-metadata.js'; +import { SnsEnvelope } from '../../src/envelopes/sns.js'; import { SqsEnvelope } from '../../src/envelopes/sqs.js'; import { ParseError } from '../../src/errors.js'; import { parser } from '../../src/middleware/index.js'; @@ -254,6 +255,28 @@ describe('Middleware: parser', () => { }); }); + it('calls the errorHandler when the envelope fails to parse', async () => { + // Act + const result = await middy() + .use( + parser({ + schema: z.string(), + envelope: SnsEnvelope, + errorHandler: (error) => ({ + errorHandled: true, + message: error.message, + }), + }) + ) + .handler((event) => event)({ Records: [] } as never, {} as Context); + + // Assess + expect(result).toEqual({ + errorHandled: true, + message: expect.stringContaining('Failed to parse SNS envelope'), + }); + }); + it('does not call the errorHandler when schema validation succeeds', async () => { // Prepare const event = structuredClone(JSONPayload);