From 1b2cfde35bd278a2ad0d720e17d4cfce6bbb35c0 Mon Sep 17 00:00:00 2001 From: Vidit Shah Date: Sun, 4 Oct 2026 13:40:15 +0530 Subject: [PATCH 1/3] feat(parser): support EventBridge WITH_METADATA deliveries --- docs/features/parser.md | 29 +++ .../parser/eventBridgeWithMetadata.ts | 50 +++++ .../eventBridgeWithMetadataDecorator.ts | 18 ++ .../parser/eventBridgeWithMetadataMiddy.ts | 12 ++ .../envelopes/eventbridge-with-metadata.ts | 70 +++++++ packages/parser/src/envelopes/index.ts | 1 + .../src/schemas/eventbridge-with-metadata.ts | 51 +++++ packages/parser/src/schemas/index.ts | 5 + packages/parser/src/types/index.ts | 3 + packages/parser/src/types/schema.ts | 21 ++ .../types/eventbridge-with-metadata.test-d.ts | 63 ++++++ .../eventbridge-with-metadata.test.ts | 136 ++++++++++++ ...ntbridge-with-metadata-integration.test.ts | 111 ++++++++++ .../unit/helpers/eventbridge-with-metadata.ts | 30 +++ .../schema/eventbridge-with-metadata.test.ts | 198 ++++++++++++++++++ 15 files changed, 798 insertions(+) create mode 100644 examples/snippets/parser/eventBridgeWithMetadata.ts create mode 100644 examples/snippets/parser/eventBridgeWithMetadataDecorator.ts create mode 100644 examples/snippets/parser/eventBridgeWithMetadataMiddy.ts create mode 100644 packages/parser/src/envelopes/eventbridge-with-metadata.ts create mode 100644 packages/parser/src/schemas/eventbridge-with-metadata.ts create mode 100644 packages/parser/tests/types/eventbridge-with-metadata.test-d.ts create mode 100644 packages/parser/tests/unit/envelopes/eventbridge-with-metadata.test.ts create mode 100644 packages/parser/tests/unit/eventbridge-with-metadata-integration.test.ts create mode 100644 packages/parser/tests/unit/helpers/eventbridge-with-metadata.ts create mode 100644 packages/parser/tests/unit/schema/eventbridge-with-metadata.test.ts diff --git a/docs/features/parser.md b/docs/features/parser.md index 89efc57165..3a270ebdad 100644 --- a/docs/features/parser.md +++ b/docs/features/parser.md @@ -238,6 +238,35 @@ Parser comes with the following built-in Zod envelopes: | **VpcLatticeEnvelope** | 1. Parses data using `VpcLatticeSchema`.
2. Parses `value` key using your schema and returns it. | | **VpcLatticeV2Envelope** | 1. Parses data using `VpcLatticeSchema`.
2. Parses `value` key using your schema and returns it. | +### EventBridge WITH_METADATA deliveries + +For [Custom Event Bus subscribers](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-custom-bus-transform.html){target="_blank"} using `WITH_METADATA`, each record wraps an event in `Data` alongside `SystemMetadata` and optional `Metadata`. +These schemas validate standard EventBridge events inside `Data`, such as those published with `PutEvents`; arbitrary `PutRawEvents` payloads require your own schema. + +`EventBridgeWithMetadataSchema` and `EventBridgeWithMetadataEnvelope` accept nonempty arrays. The envelope returns an array of parsed details and rejects the entire batch if any record fails validation. Error paths include the record index, for example `[1, "Data", "detail", "orderId"]`. Safe parsing returns the error and original batch on failure. + +`EventBridgeWithMetadataSystemMetadataSchema` validates delivery metadata. Only `aws:DeliveryType` (`LIVE` or `REPLAY`) is required in system metadata. Other documented fields are optional strings. +Sequence numbers stay strings, including values beyond JavaScript's safe integer range. Grouping and deduplication fields can occur on unordered deliveries too; their presence does not identify a FIFO subscriber. + +Use `EventBridgeWithMetadataRecordSchema` directly for single-object delivery with `MaxBatchSize: 1`. Extend it when you need both validated details and delivery metadata. The classic `EventBridgeSchema` and `EventBridgeEnvelope` remain available for classic event payloads. + +=== "Manual parsing and metadata" + ```typescript + --8<-- "examples/snippets/parser/eventBridgeWithMetadata.ts" + ``` + +=== "Middy.js middleware" + ```typescript + --8<-- "examples/snippets/parser/eventBridgeWithMetadataMiddy.ts" + ``` + +=== "Decorator" + ```typescript + --8<-- "examples/snippets/parser/eventBridgeWithMetadataDecorator.ts" + ``` + +Import the schemas from `/schemas` or `/schemas/eventbridge-with-metadata`, and the envelope from `/envelopes` or `/envelopes/eventbridge-with-metadata`. The inferred `EventBridgeWithMetadataSystemMetadata`, `EventBridgeWithMetadataRecord`, and `EventBridgeWithMetadataEvent` types are available from `@aws-lambda-powertools/parser/types`. + ## Safe parsing If you want to parse the event without throwing an error, use the `safeParse` option. diff --git a/examples/snippets/parser/eventBridgeWithMetadata.ts b/examples/snippets/parser/eventBridgeWithMetadata.ts new file mode 100644 index 0000000000..765c6fd684 --- /dev/null +++ b/examples/snippets/parser/eventBridgeWithMetadata.ts @@ -0,0 +1,50 @@ +import { parse } from '@aws-lambda-powertools/parser'; +import { EventBridgeWithMetadataEnvelope } from '@aws-lambda-powertools/parser/envelopes'; +import { + EventBridgeSchema, + EventBridgeWithMetadataRecordSchema, +} from '@aws-lambda-powertools/parser/schemas'; +import { z } from 'zod'; + +const orderSchema = z.object({ orderId: z.string() }); + +// Extract details from a nonempty batch, even when it contains only one record. +export const handler = async (event: unknown) => { + const orders = parse(event, EventBridgeWithMetadataEnvelope, orderSchema); + return orders.map((order) => order.orderId); +}; + +// Return a failed result with the original event instead of throwing. +export const safeHandler = async (event: unknown) => { + const result = parse( + event, + EventBridgeWithMetadataEnvelope, + orderSchema, + true + ); + if (!result.success) { + console.error('Invalid delivery', result.error, result.originalEvent); + return; + } + return result.data; +}; + +// Extend the record schema when you also need delivery metadata. +const recordSchema = EventBridgeWithMetadataRecordSchema.extend({ + Data: EventBridgeSchema.extend({ detail: orderSchema }), +}); +const batchSchema = recordSchema.array().nonempty(); + +export const metadataHandler = async (event: unknown) => { + const records = batchSchema.parse(event); + return records.map((record) => ({ + orderId: record.Data.detail.orderId, + deliveryType: record.SystemMetadata['aws:DeliveryType'], + // Keep sequence numbers as strings to avoid precision loss. + sequence: record.SystemMetadata['aws:SequenceNumber'], + })); +}; + +// Use the record schema for single-object delivery with MaxBatchSize: 1. +export const singleRecordHandler = async (event: unknown) => + recordSchema.parse(event); diff --git a/examples/snippets/parser/eventBridgeWithMetadataDecorator.ts b/examples/snippets/parser/eventBridgeWithMetadataDecorator.ts new file mode 100644 index 0000000000..7dea6e95cb --- /dev/null +++ b/examples/snippets/parser/eventBridgeWithMetadataDecorator.ts @@ -0,0 +1,18 @@ +import type { LambdaInterface } from '@aws-lambda-powertools/commons/types'; +import { parser } from '@aws-lambda-powertools/parser'; +import { EventBridgeWithMetadataEnvelope } from '@aws-lambda-powertools/parser/envelopes'; +import type { Context } from 'aws-lambda'; +import { z } from 'zod'; + +const orderSchema = z.object({ orderId: z.string() }); +type Order = z.infer; + +class Lambda implements LambdaInterface { + @parser({ schema: orderSchema, envelope: EventBridgeWithMetadataEnvelope }) + public async handler(orders: Order[], _context: Context): Promise { + return orders.map((order) => order.orderId); + } +} + +const lambda = new Lambda(); +export const handler = lambda.handler.bind(lambda); diff --git a/examples/snippets/parser/eventBridgeWithMetadataMiddy.ts b/examples/snippets/parser/eventBridgeWithMetadataMiddy.ts new file mode 100644 index 0000000000..6147e404a0 --- /dev/null +++ b/examples/snippets/parser/eventBridgeWithMetadataMiddy.ts @@ -0,0 +1,12 @@ +import { EventBridgeWithMetadataEnvelope } from '@aws-lambda-powertools/parser/envelopes'; +import { parser } from '@aws-lambda-powertools/parser/middleware'; +import middy from '@middy/core'; +import { z } from 'zod'; + +const orderSchema = z.object({ orderId: z.string() }); + +export const handler = middy() + .use( + parser({ schema: orderSchema, envelope: EventBridgeWithMetadataEnvelope }) + ) + .handler(async (orders) => orders.map((order) => order.orderId)); diff --git a/packages/parser/src/envelopes/eventbridge-with-metadata.ts b/packages/parser/src/envelopes/eventbridge-with-metadata.ts new file mode 100644 index 0000000000..7699ca37ba --- /dev/null +++ b/packages/parser/src/envelopes/eventbridge-with-metadata.ts @@ -0,0 +1,70 @@ +import type { ZodType } from 'zod'; +import { ParseError } from '../errors.js'; +import { EventBridgeSchema } from '../schemas/eventbridge.js'; +import { EventBridgeWithMetadataRecordSchema } from '../schemas/eventbridge-with-metadata.js'; +import type { ParsedResult } from '../types/index.js'; +import { envelopeDiscriminator } from './envelope.js'; + +/** + * Validates a WITH_METADATA batch and extracts each record's parsed `Data.detail`. + * + * Returns details in input order. Invalid records fail the whole batch, with + * validation paths relative to the original input. Use the record schema + * directly for single-object delivery or to retain delivery metadata. + */ +const EventBridgeWithMetadataEnvelope = { + /** + * Identifies the envelope's array output. + * @hidden + */ + [envelopeDiscriminator]: 'array' as const, + + /** + * Parses every record's detail using the supplied schema. + * + * @param data - the batch to parse + * @param schema - the schema for each record's detail + */ + parse(data: unknown, schema: ZodType): T[] { + try { + return EventBridgeWithMetadataRecordSchema.extend({ + Data: EventBridgeSchema.extend({ detail: schema }), + }) + .array() + .nonempty() + .parse(data) + .map((record) => record.Data.detail); + } catch (error) { + throw new ParseError( + 'Failed to parse EventBridge WITH_METADATA envelope', + { + cause: error, + } + ); + } + }, + + /** + * Parses a batch, returning the original input and error on failure. + * + * @param data - the batch to parse + * @param schema - the schema for each record's detail + */ + safeParse(data: unknown, schema: ZodType): ParsedResult { + try { + return { + success: true, + data: EventBridgeWithMetadataEnvelope.parse(data, schema), + }; + } catch (error) { + return { + success: false, + // parse always wraps validation and transform failures in ParseError. + error: error as ParseError, + originalEvent: data, + }; + } + }, +}; + +export { EventBridgeWithMetadataEnvelope }; diff --git a/packages/parser/src/envelopes/index.ts b/packages/parser/src/envelopes/index.ts index 08e77b8756..d55201f5a6 100644 --- a/packages/parser/src/envelopes/index.ts +++ b/packages/parser/src/envelopes/index.ts @@ -3,6 +3,7 @@ export { ApiGatewayV2Envelope } from './api-gatewayv2.js'; export { CloudWatchEnvelope } from './cloudwatch.js'; export { DynamoDBStreamEnvelope } from './dynamodb.js'; export { EventBridgeEnvelope } from './eventbridge.js'; +export { EventBridgeWithMetadataEnvelope } from './eventbridge-with-metadata.js'; export { KafkaEnvelope } from './kafka.js'; export { KinesisEnvelope } from './kinesis.js'; export { KinesisFirehoseEnvelope } from './kinesis-firehose.js'; diff --git a/packages/parser/src/schemas/eventbridge-with-metadata.ts b/packages/parser/src/schemas/eventbridge-with-metadata.ts new file mode 100644 index 0000000000..0e8857d8c8 --- /dev/null +++ b/packages/parser/src/schemas/eventbridge-with-metadata.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; +import { EventBridgeSchema } from './eventbridge.js'; + +/** + * Validates system metadata for an EventBridge WITH_METADATA delivery. + * + * Only `aws:DeliveryType` is required. All metadata values, including sequence + * numbers that can exceed JavaScript's safe integer range, remain strings. + * + * @see https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-custom-bus-addressing.html + */ +const EventBridgeWithMetadataSystemMetadataSchema = z.object({ + 'aws:DeliveryType': z.enum(['LIVE', 'REPLAY']), + ContentType: z.string().optional(), + EventGroupId: z.string().optional(), + DeduplicationId: z.string().optional(), + 'aws:EventId': z.string().optional(), + 'aws:IngestionTime': z.string().optional(), + 'aws:SequenceNumber': z.string().optional(), + 'aws:Source': z.string().optional(), + 'aws:DetailType': z.string().optional(), + 'aws:SchemaId': z.string().optional(), + 'aws:RegistryType': z.string().optional(), +}); + +/** + * Validates one WITH_METADATA record containing a standard EventBridge event. + * + * Use this schema directly for single-object delivery with `MaxBatchSize: 1`. + * Arbitrary `PutRawEvents` payloads are outside this schema's scope. + */ +const EventBridgeWithMetadataRecordSchema = z.object({ + Data: EventBridgeSchema, + Metadata: z.record(z.string(), z.string()).optional(), + SystemMetadata: EventBridgeWithMetadataSystemMetadataSchema, +}); + +/** + * Validates a nonempty batch of WITH_METADATA records. + * + * Extend {@link EventBridgeWithMetadataRecordSchema | `EventBridgeWithMetadataRecordSchema`} + * before creating an array schema to validate application details and retain metadata. + */ +const EventBridgeWithMetadataSchema = + EventBridgeWithMetadataRecordSchema.array().nonempty(); + +export { + EventBridgeWithMetadataRecordSchema, + EventBridgeWithMetadataSchema, + EventBridgeWithMetadataSystemMetadataSchema, +}; diff --git a/packages/parser/src/schemas/index.ts b/packages/parser/src/schemas/index.ts index 5e09159e9c..4c0219a29f 100644 --- a/packages/parser/src/schemas/index.ts +++ b/packages/parser/src/schemas/index.ts @@ -58,6 +58,11 @@ export { DynamoDBStreamToKinesisRecord, } from './dynamodb.js'; export { EventBridgeSchema } from './eventbridge.js'; +export { + EventBridgeWithMetadataRecordSchema, + EventBridgeWithMetadataSchema, + EventBridgeWithMetadataSystemMetadataSchema, +} from './eventbridge-with-metadata.js'; export { KafkaMskEventSchema, KafkaRecordSchema, diff --git a/packages/parser/src/types/index.ts b/packages/parser/src/types/index.ts index dd4e56a842..5130910c65 100644 --- a/packages/parser/src/types/index.ts +++ b/packages/parser/src/types/index.ts @@ -39,6 +39,9 @@ export type { DynamoDBStreamRecord, DynamoDBStreamToKinesisRecordEvent, EventBridgeEvent, + EventBridgeWithMetadataEvent, + EventBridgeWithMetadataRecord, + EventBridgeWithMetadataSystemMetadata, KafkaMskEvent, KafkaRecord, KafkaSelfManagedEvent, diff --git a/packages/parser/src/types/schema.ts b/packages/parser/src/types/schema.ts index 9195e357dc..2c5fd98139 100644 --- a/packages/parser/src/types/schema.ts +++ b/packages/parser/src/types/schema.ts @@ -24,6 +24,9 @@ import type { DynamoDBStreamSchema, DynamoDBStreamToKinesisRecord, EventBridgeSchema, + EventBridgeWithMetadataRecordSchema, + EventBridgeWithMetadataSchema, + EventBridgeWithMetadataSystemMetadataSchema, KafkaMskEventSchema, KafkaRecordSchema, KafkaSelfManagedEventSchema, @@ -122,6 +125,21 @@ type DynamoDBStreamToKinesisRecordEvent = z.infer< type EventBridgeEvent = z.infer; +/** Represents the system metadata of a WITH_METADATA delivery. */ +type EventBridgeWithMetadataSystemMetadata = z.infer< + typeof EventBridgeWithMetadataSystemMetadataSchema +>; + +/** Represents one WITH_METADATA record containing a standard EventBridge event. */ +type EventBridgeWithMetadataRecord = z.infer< + typeof EventBridgeWithMetadataRecordSchema +>; + +/** Represents a nonempty batch of WITH_METADATA records. */ +type EventBridgeWithMetadataEvent = z.infer< + typeof EventBridgeWithMetadataSchema +>; + type KafkaSelfManagedEvent = z.infer; type KafkaRecord = z.infer; @@ -201,6 +219,9 @@ export type { DynamoDBStreamRecord, DynamoDBStreamToKinesisRecordEvent, EventBridgeEvent, + EventBridgeWithMetadataEvent, + EventBridgeWithMetadataRecord, + EventBridgeWithMetadataSystemMetadata, KafkaMskEvent, KafkaRecord, KafkaSelfManagedEvent, diff --git a/packages/parser/tests/types/eventbridge-with-metadata.test-d.ts b/packages/parser/tests/types/eventbridge-with-metadata.test-d.ts new file mode 100644 index 0000000000..9fd6284bee --- /dev/null +++ b/packages/parser/tests/types/eventbridge-with-metadata.test-d.ts @@ -0,0 +1,63 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { z } from 'zod'; +import { EventBridgeWithMetadataEnvelope } from '../../src/envelopes/index.js'; +import { parse } from '../../src/parser.js'; +import type { + EventBridgeWithMetadataRecordSchema, + EventBridgeWithMetadataSchema, +} from '../../src/schemas/index.js'; +import type { + EventBridgeWithMetadataEvent, + EventBridgeWithMetadataRecord, + EventBridgeWithMetadataSystemMetadata, + ParsedResult, +} from '../../src/types/index.js'; +import type { ParserOutput } from '../../src/types/parser.js'; + +describe('Types: EventBridge WITH_METADATA', () => { + it('infers transformed detail arrays for parsing and safe parsing', () => { + // Prepare + const schema = z.object({ orderId: z.string().transform(Number) }); + const event: unknown = []; + + // Act + const result = parse(event, EventBridgeWithMetadataEnvelope, schema); + const safeResult = parse( + event, + EventBridgeWithMetadataEnvelope, + schema, + true + ); + + // Assess + expectTypeOf(result).toEqualTypeOf<{ orderId: number }[]>(); + expectTypeOf(safeResult).toEqualTypeOf< + ParsedResult + >(); + expectTypeOf< + ParserOutput + >().toEqualTypeOf<{ orderId: number }[]>(); + }); + + it('exports inferred record and batch types', () => { + // Assess + expectTypeOf< + z.infer + >().toEqualTypeOf(); + expectTypeOf< + z.infer + >().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + EventBridgeWithMetadataRecord[] + >(); + expectTypeOf().toEqualTypeOf< + Record | undefined + >(); + expectTypeOf< + EventBridgeWithMetadataSystemMetadata['aws:SequenceNumber'] + >().toEqualTypeOf(); + expectTypeOf< + EventBridgeWithMetadataSystemMetadata['aws:DeliveryType'] + >().toEqualTypeOf<'LIVE' | 'REPLAY'>(); + }); +}); diff --git a/packages/parser/tests/unit/envelopes/eventbridge-with-metadata.test.ts b/packages/parser/tests/unit/envelopes/eventbridge-with-metadata.test.ts new file mode 100644 index 0000000000..e71fface2b --- /dev/null +++ b/packages/parser/tests/unit/envelopes/eventbridge-with-metadata.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { EventBridgeWithMetadataEnvelope } from '../../../src/envelopes/eventbridge-with-metadata.js'; +import { ParseError } from '../../../src/errors.js'; +import { parse } from '../../../src/parser.js'; +import { makeRecord } from '../helpers/eventbridge-with-metadata.js'; + +describe('Envelope: EventBridge WITH_METADATA', () => { + const schema = z.object({ orderId: z.string().transform(Number) }); + + it.each([1, 3])('parses and transforms %i records in order', (count) => { + // Prepare + const event = Array.from({ length: count }, (_, index) => + makeRecord({ orderId: `${index}` }) + ); + const expected = Array.from({ length: count }, (_, index) => ({ + orderId: index, + })); + + // Act + const result = parse(event, EventBridgeWithMetadataEnvelope, schema); + const safeResult = parse( + event, + EventBridgeWithMetadataEnvelope, + schema, + true + ); + + // Assess + expect(result).toEqual(expected); + expect(safeResult).toEqual({ success: true, data: expected }); + expect(event[0].Data.detail).toEqual({ orderId: '0' }); + }); + + it.each([ + { + name: 'invalid detail', + event: [makeRecord({ orderId: '1' }), makeRecord({ orderId: 2 })], + path: [1, 'Data', 'detail', 'orderId'], + }, + { + name: 'invalid metadata', + event: [{ ...makeRecord({ orderId: '1' }), SystemMetadata: {} }], + path: [0, 'SystemMetadata', 'aws:DeliveryType'], + }, + { + name: 'invalid producer metadata', + event: [{ ...makeRecord({ orderId: '1' }), Metadata: { tenant: 123 } }], + path: [0, 'Metadata', 'tenant'], + }, + { + name: 'invalid data', + event: [{ ...makeRecord({ orderId: '1' }), Data: null }], + path: [0, 'Data'], + }, + { name: 'empty batch', event: [], path: [] }, + { name: 'single object', event: makeRecord({ orderId: '1' }), path: [] }, + ])( + 'fails the whole batch for $name and preserves the issue path', + ({ event, path }) => { + // Act + const result = EventBridgeWithMetadataEnvelope.safeParse(event, schema); + + // Assess + expect(result).toEqual({ + success: false, + originalEvent: event, + error: expect.objectContaining({ + name: 'ParseError', + cause: expect.objectContaining({ + issues: expect.arrayContaining([expect.objectContaining({ path })]), + }), + }), + }); + if (!result.success) { + expect(result.originalEvent).toBe(event); + expect(result.error.cause).toBeInstanceOf(z.ZodError); + } + expect(() => + EventBridgeWithMetadataEnvelope.parse(event, schema) + ).toThrow(ParseError); + expect(result).not.toHaveProperty('data'); + } + ); + + it('retains validation errors for multiple invalid records', () => { + // Prepare + const event = [makeRecord({}), makeRecord({})]; + + // Act + const result = EventBridgeWithMetadataEnvelope.safeParse(event, schema); + + // Assess + expect(result.success).toBe(false); + if (!result.success) { + const cause = result.error.cause; + expect(cause).toBeInstanceOf(z.ZodError); + if (cause instanceof z.ZodError) { + expect(cause.issues.map((issue) => issue.path)).toEqual([ + [0, 'Data', 'detail', 'orderId'], + [1, 'Data', 'detail', 'orderId'], + ]); + } + } + }); + + it.each([new SyntaxError('invalid JSON'), 'transform failed'])( + 'preserves a thrown transform cause %s', + (cause) => { + // Prepare + const event = [makeRecord({ orderId: '1' })]; + const throwingSchema = z.unknown().transform(() => { + throw cause; + }); + + // Act + const result = parse( + event, + EventBridgeWithMetadataEnvelope, + throwingSchema, + true + ); + + // Assess + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBeInstanceOf(ParseError); + expect(result.error.cause).toBe(cause); + expect(result.originalEvent).toBe(event); + } + expect(() => + EventBridgeWithMetadataEnvelope.parse(event, throwingSchema) + ).toThrow(expect.objectContaining({ cause })); + } + ); +}); diff --git a/packages/parser/tests/unit/eventbridge-with-metadata-integration.test.ts b/packages/parser/tests/unit/eventbridge-with-metadata-integration.test.ts new file mode 100644 index 0000000000..7166ee6803 --- /dev/null +++ b/packages/parser/tests/unit/eventbridge-with-metadata-integration.test.ts @@ -0,0 +1,111 @@ +import type { LambdaInterface } from '@aws-lambda-powertools/commons/types'; +import middy from '@middy/core'; +import type { Context, Handler } from 'aws-lambda'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { EventBridgeWithMetadataEnvelope } from '../../src/envelopes/index.js'; +import { parser } from '../../src/index.js'; +import { parser as parserMiddleware } from '../../src/middleware/index.js'; +import type { ParsedResult } from '../../src/types/index.js'; +import { makeRecord } from './helpers/eventbridge-with-metadata.js'; + +describe('Integration: EventBridge WITH_METADATA', () => { + const schema = z.object({ orderId: z.string().transform(Number) }); + type Order = z.infer; + + it('passes transformed detail arrays through the decorator', async () => { + // Prepare + class Lambda implements LambdaInterface { + @parser({ schema, envelope: EventBridgeWithMetadataEnvelope }) + public async handler(event: Order[], _context: Context) { + return event; + } + } + // Invoke through the Lambda boundary, which receives the unparsed event. + const lambda: LambdaInterface = new Lambda(); + const event = [makeRecord({ orderId: '42' })]; + + // Act + const result = await lambda.handler(event, {} as Context, () => {}); + + // Assess + expect(result).toEqual([{ orderId: 42 }]); + }); + + it('passes transformed detail arrays through middleware', async () => { + // Prepare + const handler: Handler = middy() + .use( + parserMiddleware({ schema, envelope: EventBridgeWithMetadataEnvelope }) + ) + .handler(async (event) => event); + const event = [makeRecord({ orderId: '42' })]; + + // Act + const result = await handler(event, {} as Context, () => {}); + + // Assess + expect(result).toEqual([{ orderId: 42 }]); + }); + + it('passes a failed result through the decorator for a throwing transform', async () => { + // Prepare + const cause = new SyntaxError('invalid JSON'); + const throwingSchema = z.unknown().transform(() => { + throw cause; + }); + class Lambda implements LambdaInterface { + @parser({ + schema: throwingSchema, + envelope: EventBridgeWithMetadataEnvelope, + safeParse: true, + }) + public async handler( + event: ParsedResult, + _context: Context + ) { + return event; + } + } + const lambda: LambdaInterface = new Lambda(); + const event = [makeRecord({})]; + + // Act + const result = await lambda.handler(event, {} as Context, () => {}); + + // Assess + expect(result).toMatchObject({ + success: false, + originalEvent: event, + error: { cause }, + }); + }); + + it('passes a failed result through middleware for a throwing transform', async () => { + // Prepare + const cause = new SyntaxError('invalid JSON'); + const throwingSchema = z.unknown().transform(() => { + throw cause; + }); + const handler: Handler = middy() + .use( + parserMiddleware({ + schema: throwingSchema, + envelope: EventBridgeWithMetadataEnvelope, + safeParse: true, + }) + ) + .handler(async (event) => event); + const event = [makeRecord({})]; + + // Act + const result = await handler(event, {} as Context, () => {}); + + // Assess + expect(result).toMatchObject({ + success: false, + originalEvent: event, + error: { cause }, + }); + }); +}); diff --git a/packages/parser/tests/unit/helpers/eventbridge-with-metadata.ts b/packages/parser/tests/unit/helpers/eventbridge-with-metadata.ts new file mode 100644 index 0000000000..1761f3da9e --- /dev/null +++ b/packages/parser/tests/unit/helpers/eventbridge-with-metadata.ts @@ -0,0 +1,30 @@ +import type { + EventBridgeEvent, + EventBridgeWithMetadataRecord, +} from '../../../src/types/index.js'; +import { getTestEvent } from './utils.js'; + +/** + * Creates a synthetic WITH_METADATA record from the classic EventBridge fixture. + * + * @param detail - the application payload + */ +const makeRecord = (detail: unknown): EventBridgeWithMetadataRecord => ({ + Data: { + ...getTestEvent({ + eventsPath: 'eventbridge', + filename: 'base', + }), + detail, + }, + Metadata: {}, + SystemMetadata: { + 'aws:DeliveryType': 'LIVE', + 'aws:EventId': 'bus-event-id', + 'aws:SequenceNumber': '10000000000000003000', + EventGroupId: 'group-1', + DeduplicationId: 'dedup-1', + }, +}); + +export { makeRecord }; diff --git a/packages/parser/tests/unit/schema/eventbridge-with-metadata.test.ts b/packages/parser/tests/unit/schema/eventbridge-with-metadata.test.ts new file mode 100644 index 0000000000..b31a946f80 --- /dev/null +++ b/packages/parser/tests/unit/schema/eventbridge-with-metadata.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { EventBridgeSchema } from '../../../src/schemas/eventbridge.js'; +import { + EventBridgeWithMetadataRecordSchema, + EventBridgeWithMetadataSchema, + EventBridgeWithMetadataSystemMetadataSchema, +} from '../../../src/schemas/eventbridge-with-metadata.js'; +import { makeRecord } from '../helpers/eventbridge-with-metadata.js'; +import { omit } from '../helpers/utils.js'; + +describe('Schema: EventBridge WITH_METADATA', () => { + it.each([1, 3])('parses a batch of %i records in order', (count) => { + // Prepare + const event = Array.from({ length: count }, (_, index) => + makeRecord({ orderId: `order-${index}` }) + ); + + // Act + const result = EventBridgeWithMetadataSchema.parse(event); + + // Assess + expect(result).toStrictEqual(event); + }); + + it.each(['900719925474099301', '10000000000000003000'])( + 'preserves sequence %s exactly', + (sequence) => { + // Prepare + const event = makeRecord({ orderId: '1' }); + event.SystemMetadata['aws:SequenceNumber'] = sequence; + + // Act + const result = EventBridgeWithMetadataRecordSchema.parse(event); + + // Assess + expect(result.SystemMetadata['aws:SequenceNumber']).toBe(sequence); + expect(result.Data.id).not.toBe(result.SystemMetadata['aws:EventId']); + } + ); + + it.each(['LIVE', 'REPLAY'])( + 'accepts %s with only required system metadata', + (deliveryType) => { + // Prepare: synthetic omission case based on documented optionality. + const event = { + ...makeRecord({ orderId: '1' }), + SystemMetadata: { 'aws:DeliveryType': deliveryType }, + }; + + // Act + const result = EventBridgeWithMetadataRecordSchema.parse(event); + + // Assess + expect(result.SystemMetadata).toStrictEqual({ + 'aws:DeliveryType': deliveryType, + }); + } + ); + + it('retains replay names and independent event identifiers', () => { + // Prepare + const event = makeRecord({ orderId: '1' }); + event.Data['replay-name'] = 'orders-replay'; + event.SystemMetadata['aws:DeliveryType'] = 'REPLAY'; + + // Act + const result = EventBridgeWithMetadataRecordSchema.parse(event); + + // Assess + expect(result).toStrictEqual(event); + }); + + it.each([undefined, {}, { tenant: 'example' }])( + 'accepts optional producer metadata %j', + (metadata) => { + // Prepare + const record = omit(['Metadata'], makeRecord({ orderId: '1' })); + const event = + metadata === undefined ? record : { ...record, Metadata: metadata }; + + // Act + const result = EventBridgeWithMetadataRecordSchema.parse(event); + + // Assess + expect(result).toStrictEqual(event); + } + ); + + it.each([[], {}, null, undefined, 'event', makeRecord({ orderId: '1' })])( + 'rejects a non-batch input %j', + (event) => { + // Act + const result = EventBridgeWithMetadataSchema.safeParse(event); + + // Assess + expect(result.success).toBe(false); + } + ); + + it.each([ + undefined, + null, + [], + 'metadata', + {}, + { 'aws:DeliveryType': 'OTHER' }, + { 'aws:DeliveryType': 1 }, + ])('rejects invalid system metadata %j', (systemMetadata) => { + // Prepare + const event = { + ...makeRecord({ orderId: '1' }), + SystemMetadata: systemMetadata, + }; + + // Act + const result = EventBridgeWithMetadataRecordSchema.safeParse(event); + + // Assess + expect(result.success).toBe(false); + }); + + it.each([ + 'ContentType', + 'EventGroupId', + 'DeduplicationId', + 'aws:EventId', + 'aws:IngestionTime', + 'aws:SequenceNumber', + 'aws:Source', + 'aws:DetailType', + 'aws:SchemaId', + 'aws:RegistryType', + ])('requires a string for optional field %s', (field) => { + // Prepare + const valid = { 'aws:DeliveryType': 'LIVE', [field]: 'value' }; + const invalid = { ...valid, [field]: 123 }; + + // Act + const accepted = EventBridgeWithMetadataSystemMetadataSchema.parse(valid); + const rejected = + EventBridgeWithMetadataSystemMetadataSchema.safeParse(invalid); + + // Assess + expect(accepted).toEqual(valid); + expect(rejected.success).toBe(false); + }); + + it.each([null, [], 'metadata', { tenant: 1 }])( + 'rejects invalid producer metadata %j', + (metadata) => { + // Prepare + const event = { ...makeRecord({ orderId: '1' }), Metadata: metadata }; + + // Act + const result = EventBridgeWithMetadataRecordSchema.safeParse(event); + + // Assess + expect(result.success).toBe(false); + } + ); + + it.each([ + null, + undefined, + { orderId: 'raw-event' }, + { ...makeRecord({}).Data, time: 'invalid' }, + omit(['detail-type'], makeRecord({}).Data), + ])('rejects malformed standard event data %j', (data) => { + // Prepare + const event = { ...makeRecord({ orderId: '1' }), Data: data }; + + // Act + const result = EventBridgeWithMetadataRecordSchema.safeParse(event); + + // Assess + expect(result.success).toBe(false); + }); + + it('extends the record schema to parse details and retain metadata', () => { + // Prepare + const schema = EventBridgeWithMetadataRecordSchema.extend({ + Data: EventBridgeSchema.extend({ + detail: z.object({ orderId: z.string().transform(Number) }), + }), + }); + const event = makeRecord({ orderId: '42' }); + event.Metadata = { tenant: 'example' }; + + // Act + const result = schema.parse(event); + + // Assess + expect(result.Data.detail).toEqual({ orderId: 42 }); + expect(result.Metadata).toEqual({ tenant: 'example' }); + expect(result.SystemMetadata).toEqual(event.SystemMetadata); + }); +}); From b17b4d445b78d349fe52a397575eda07256100e4 Mon Sep 17 00:00:00 2001 From: Vidit Shah Date: Sun, 4 Oct 2026 23:19:16 +0530 Subject: [PATCH 2/3] refactor(parser): align WITH_METADATA tests and docs with conventions --- docs/features/parser.md | 163 ++++++++---------- .../parser/eventBridgeWithMetadata.ts | 50 ------ .../eventBridgeWithMetadataDecorator.ts | 18 -- .../parser/eventBridgeWithMetadataMiddy.ts | 12 -- .../eventbridge-with-metadata.test.ts | 40 ++++- ...ntbridge-with-metadata-integration.test.ts | 10 +- .../unit/helpers/eventbridge-with-metadata.ts | 30 ---- packages/parser/tests/unit/helpers/utils.ts | 31 +++- .../schema/eventbridge-with-metadata.test.ts | 56 +++--- 9 files changed, 169 insertions(+), 241 deletions(-) delete mode 100644 examples/snippets/parser/eventBridgeWithMetadata.ts delete mode 100644 examples/snippets/parser/eventBridgeWithMetadataDecorator.ts delete mode 100644 examples/snippets/parser/eventBridgeWithMetadataMiddy.ts delete mode 100644 packages/parser/tests/unit/helpers/eventbridge-with-metadata.ts diff --git a/docs/features/parser.md b/docs/features/parser.md index 3a270ebdad..508a0ea75f 100644 --- a/docs/features/parser.md +++ b/docs/features/parser.md @@ -53,55 +53,58 @@ When using the decorator or middleware, you can specify a schema to parse the ev !!! note "Looking for other libraries?" The built-in schemas are defined using Zod, if you would like us to support other libraries like [valibot](https://valibot.dev){target="_blank"} please [open an issue](https://github.com/aws-powertools/powertools-lambda-typescript/issues/new?template=feature_request.yml){target="_blank"} and we will consider it based on the community's feedback. -| Model name | Description | -| -------------------------------------------- | ------------------------------------------------------------------------------------- | -| **AlbSchema** | Lambda Event Source payload for Amazon Application Load Balancer | -| **APIGatewayProxyEventSchema** | Lambda Event Source payload for Amazon API Gateway | -| **APIGatewayRequestAuthorizerEventSchema** | Lambda Event Source payload for Amazon API Gateway Request Authorizer | -| **APIGatewayTokenAuthorizerEventSchema** | Lambda Event Source payload for Amazon API Gateway Token Authorizer | -| **APIGatewayProxyEventV2Schema** | Lambda Event Source payload for Amazon API Gateway v2 payload | -| **APIGatewayProxyWebsocketEventSchema** | Lambda Event Source payload for Amazon API Gateway WebSocket events | -| **APIGatewayRequestAuthorizerEventV2Schema** | Lambda Event Source payload for Amazon API Gateway v2 Authorizer | -| **AppSyncResolverSchema** | Lambda Event Source payload for AWS AppSync GraphQL API resolver | -| **AppSyncBatchResolverSchema** | Lambda Event Source payload for AWS AppSync GraphQL API batch resolver | -| **AppSyncEventsPublishSchema** | Lambda Event Source payload for AWS AppSync Events API `PUBLISH` operation | -| **AppSyncEventsSubscribeSchema** | Lambda Event Source payload for AWS AppSync Events API `SUBSCRIBE` operation | -| **CloudFormationCustomResourceCreateSchema** | Lambda Event Source payload for AWS CloudFormation `CREATE` operation | -| **CloudFormationCustomResourceUpdateSchema** | Lambda Event Source payload for AWS CloudFormation `UPDATE` operation | -| **CloudFormationCustomResourceDeleteSchema** | Lambda Event Source payload for AWS CloudFormation `DELETE` operation | -| **CloudwatchLogsSchema** | Lambda Event Source payload for Amazon CloudWatch Logs | -| **PreSignupTriggerSchema** | Lambda Event Source payload for Amazon Cognito Pre Sign-up trigger | -| **PostConfirmationTriggerSchema** | Lambda Event Source payload for Amazon Cognito Post Confirmation trigger | -| **PreTokenGenerationTriggerSchema** | Lambda Event Source payload for Amazon Cognito Pre Token Generation trigger | -| **CustomMessageTriggerSchema** | Lambda Event Source payload for Amazon Cognito Custom Message trigger | -| **MigrateUserTriggerSchema** | Lambda Event Source payload for Amazon Cognito User Migration trigger | -| **CustomSMSTriggerSchema** | Lambda Event Source payload for Amazon Cognito Custom SMS trigger | -| **CustomEmailTriggerSchema** | Lambda Event Source payload for Amazon Cognito Custom Email trigger | -| **DefineAuthChallengeTriggerSchema** | Lambda Event Source payload for Amazon Cognito Define Auth Challenge trigger | -| **CreateAuthChallengeTriggerSchema** | Lambda Event Source payload for Amazon Cognito Create Auth Challenge trigger | -| **VerifyAuthChallengeResponseTriggerSchema** | Lambda Event Source payload for Amazon Cognito Verify Auth Challenge Response trigger | -| **PreTokenGenerationTriggerSchemaV1** | Lambda Event Source payload for Amazon Cognito Pre Token Generation trigger v1 | -| **PreTokenGenerationTriggerSchemaV2AndV3** | Lambda Event Source payload for Amazon Cognito Pre Token Generation trigger v2 and v3 | -| **ConnectOutboundCampaignsSchema** | Lambda Event Source payload for Amazon Connect Outbound Campaigns custom actions | -| **DynamoDBStreamSchema** | Lambda Event Source payload for Amazon DynamoDB Streams | -| **EventBridgeSchema** | Lambda Event Source payload for Amazon EventBridge | -| **KafkaMskEventSchema** | Lambda Event Source payload for AWS MSK payload | -| **KafkaSelfManagedEventSchema** | Lambda Event Source payload for self managed Kafka payload | -| **KinesisDataStreamSchema** | Lambda Event Source payload for Amazon Kinesis Data Streams | -| **KinesisFirehoseSchema** | Lambda Event Source payload for Amazon Kinesis Firehose | -| **KinesisDynamoDBStreamSchema** | Lambda Event Source payload for DynamodbStream record wrapped in Kinesis Data stream | -| **KinesisFirehoseSqsSchema** | Lambda Event Source payload for SQS messages wrapped in Kinesis Firehose records | -| **LambdaFunctionUrlSchema** | Lambda Event Source payload for Lambda Function URL payload | -| **S3EventNotificationEventBridgeSchema** | Lambda Event Source payload for Amazon S3 Event Notification to EventBridge. | -| **S3Schema** | Lambda Event Source payload for Amazon S3 | -| **S3ObjectLambdaEvent** | Lambda Event Source payload for Amazon S3 Object Lambda | -| **S3SqsEventNotificationSchema** | Lambda Event Source payload for S3 event notifications wrapped in SQS event (S3->SQS) | -| **SesSchema** | Lambda Event Source payload for Amazon Simple Email Service | -| **SnsSchema** | Lambda Event Source payload for Amazon Simple Notification Service | -| **SqsSchema** | Lambda Event Source payload for Amazon SQS | -| **TransferFamilySchema** | Lambda Event Source payload for AWS Transfer Family events | -| **VpcLatticeSchema** | Lambda Event Source payload for Amazon VPC Lattice | -| **VpcLatticeV2Schema** | Lambda Event Source payload for Amazon VPC Lattice v2 payload | +| Model name | Description | +| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| **AlbSchema** | Lambda Event Source payload for Amazon Application Load Balancer | +| **APIGatewayProxyEventSchema** | Lambda Event Source payload for Amazon API Gateway | +| **APIGatewayRequestAuthorizerEventSchema** | Lambda Event Source payload for Amazon API Gateway Request Authorizer | +| **APIGatewayTokenAuthorizerEventSchema** | Lambda Event Source payload for Amazon API Gateway Token Authorizer | +| **APIGatewayProxyEventV2Schema** | Lambda Event Source payload for Amazon API Gateway v2 payload | +| **APIGatewayProxyWebsocketEventSchema** | Lambda Event Source payload for Amazon API Gateway WebSocket events | +| **APIGatewayRequestAuthorizerEventV2Schema** | Lambda Event Source payload for Amazon API Gateway v2 Authorizer | +| **AppSyncResolverSchema** | Lambda Event Source payload for AWS AppSync GraphQL API resolver | +| **AppSyncBatchResolverSchema** | Lambda Event Source payload for AWS AppSync GraphQL API batch resolver | +| **AppSyncEventsPublishSchema** | Lambda Event Source payload for AWS AppSync Events API `PUBLISH` operation | +| **AppSyncEventsSubscribeSchema** | Lambda Event Source payload for AWS AppSync Events API `SUBSCRIBE` operation | +| **CloudFormationCustomResourceCreateSchema** | Lambda Event Source payload for AWS CloudFormation `CREATE` operation | +| **CloudFormationCustomResourceUpdateSchema** | Lambda Event Source payload for AWS CloudFormation `UPDATE` operation | +| **CloudFormationCustomResourceDeleteSchema** | Lambda Event Source payload for AWS CloudFormation `DELETE` operation | +| **CloudwatchLogsSchema** | Lambda Event Source payload for Amazon CloudWatch Logs | +| **PreSignupTriggerSchema** | Lambda Event Source payload for Amazon Cognito Pre Sign-up trigger | +| **PostConfirmationTriggerSchema** | Lambda Event Source payload for Amazon Cognito Post Confirmation trigger | +| **PreTokenGenerationTriggerSchema** | Lambda Event Source payload for Amazon Cognito Pre Token Generation trigger | +| **CustomMessageTriggerSchema** | Lambda Event Source payload for Amazon Cognito Custom Message trigger | +| **MigrateUserTriggerSchema** | Lambda Event Source payload for Amazon Cognito User Migration trigger | +| **CustomSMSTriggerSchema** | Lambda Event Source payload for Amazon Cognito Custom SMS trigger | +| **CustomEmailTriggerSchema** | Lambda Event Source payload for Amazon Cognito Custom Email trigger | +| **DefineAuthChallengeTriggerSchema** | Lambda Event Source payload for Amazon Cognito Define Auth Challenge trigger | +| **CreateAuthChallengeTriggerSchema** | Lambda Event Source payload for Amazon Cognito Create Auth Challenge trigger | +| **VerifyAuthChallengeResponseTriggerSchema** | Lambda Event Source payload for Amazon Cognito Verify Auth Challenge Response trigger | +| **PreTokenGenerationTriggerSchemaV1** | Lambda Event Source payload for Amazon Cognito Pre Token Generation trigger v1 | +| **PreTokenGenerationTriggerSchemaV2AndV3** | Lambda Event Source payload for Amazon Cognito Pre Token Generation trigger v2 and v3 | +| **ConnectOutboundCampaignsSchema** | Lambda Event Source payload for Amazon Connect Outbound Campaigns custom actions | +| **DynamoDBStreamSchema** | Lambda Event Source payload for Amazon DynamoDB Streams | +| **EventBridgeSchema** | Lambda Event Source payload for Amazon EventBridge | +| **EventBridgeWithMetadataSchema** | Nonempty batch of EventBridge `WITH_METADATA` records containing standard EventBridge events. | +| **EventBridgeWithMetadataRecordSchema** | One EventBridge `WITH_METADATA` record, including single-object delivery with `MaxBatchSize: 1`. | +| **EventBridgeWithMetadataSystemMetadataSchema** | EventBridge delivery metadata; requires `aws:DeliveryType` (`LIVE` or `REPLAY`) and keeps optional fields as strings. | +| **KafkaMskEventSchema** | Lambda Event Source payload for AWS MSK payload | +| **KafkaSelfManagedEventSchema** | Lambda Event Source payload for self managed Kafka payload | +| **KinesisDataStreamSchema** | Lambda Event Source payload for Amazon Kinesis Data Streams | +| **KinesisFirehoseSchema** | Lambda Event Source payload for Amazon Kinesis Firehose | +| **KinesisDynamoDBStreamSchema** | Lambda Event Source payload for DynamodbStream record wrapped in Kinesis Data stream | +| **KinesisFirehoseSqsSchema** | Lambda Event Source payload for SQS messages wrapped in Kinesis Firehose records | +| **LambdaFunctionUrlSchema** | Lambda Event Source payload for Lambda Function URL payload | +| **S3EventNotificationEventBridgeSchema** | Lambda Event Source payload for Amazon S3 Event Notification to EventBridge. | +| **S3Schema** | Lambda Event Source payload for Amazon S3 | +| **S3ObjectLambdaEvent** | Lambda Event Source payload for Amazon S3 Object Lambda | +| **S3SqsEventNotificationSchema** | Lambda Event Source payload for S3 event notifications wrapped in SQS event (S3->SQS) | +| **SesSchema** | Lambda Event Source payload for Amazon Simple Email Service | +| **SnsSchema** | Lambda Event Source payload for Amazon Simple Notification Service | +| **SqsSchema** | Lambda Event Source payload for Amazon SQS | +| **TransferFamilySchema** | Lambda Event Source payload for AWS Transfer Family events | +| **VpcLatticeSchema** | Lambda Event Source payload for Amazon VPC Lattice | +| **VpcLatticeV2Schema** | Lambda Event Source payload for Amazon VPC Lattice v2 payload | ### Extend built-in schemas @@ -221,51 +224,23 @@ Parser comes with the following built-in Zod envelopes: !!! note "Looking for other libraries?" The built-in schemas are defined using Zod, if you would like us to support other libraries like [valibot](https://valibot.dev){target="_blank"} please [open an issue](https://github.com/aws-powertools/powertools-lambda-typescript/issues/new?template=feature_request.yml){target="_blank"} and we will consider it based on the community's feedback. -| Envelope name | Behaviour | -| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **ApiGatewayEnvelope** | 1. Parses data using `APIGatewayProxyEventSchema`.
2. Parses `body` key using your schema and returns it. | -| **ApiGatewayV2Envelope** | 1. Parses data using `APIGatewayProxyEventV2Schema`.
2. Parses `body` key using your schema and returns it. | -| **CloudWatchEnvelope** | 1. Parses data using `CloudwatchLogsSchema` which will base64 decode and decompress it.
2. Parses records in `message` key using your schema and return them in a list. | -| **DynamoDBStreamEnvelope** | 1. Parses data using `DynamoDBStreamSchema`.
2. Parses records in `NewImage` and `OldImage` keys using your schema.
3. Returns a list with a dictionary containing `NewImage` and `OldImage` keys | -| **EventBridgeEnvelope** | 1. Parses data using `EventBridgeSchema`.
2. Parses `detail` key using your schema and returns it. | -| **KafkaEnvelope** | 1. Parses data using `KafkaRecordSchema`.
2. Parses `value` key using your schema and returns it. | -| **KinesisEnvelope** | 1. Parses data using `KinesisDataStreamSchema` which will base64 decode it.
2. Parses records in `Records` key using your schema and returns them in a list. | -| **KinesisFirehoseEnvelope** | 1. Parses data using `KinesisFirehoseSchema` which will base64 decode it.
2. Parses records in `Records` key using your schema and returns them in a list. | -| **LambdaFunctionUrlEnvelope** | 1. Parses data using `LambdaFunctionUrlSchema`.
2. Parses `body` key using your schema and returns it. | -| **SnsEnvelope** | 1. Parses data using `SnsSchema`.
2. Parses records in `body` key using your schema and return them in a list. | -| **SnsSqsEnvelope** | 1. Parses data using `SqsSchema`.
2. Parses SNS records in `body` key using `SnsNotificationSchema`.
3. Parses data in `Message` key using your schema and return them in a list. | -| **SqsEnvelope** | 1. Parses data using `SqsSchema`.
2. Parses records in `body` key using your schema and return them in a list. | -| **VpcLatticeEnvelope** | 1. Parses data using `VpcLatticeSchema`.
2. Parses `value` key using your schema and returns it. | -| **VpcLatticeV2Envelope** | 1. Parses data using `VpcLatticeSchema`.
2. Parses `value` key using your schema and returns it. | - -### EventBridge WITH_METADATA deliveries - -For [Custom Event Bus subscribers](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-custom-bus-transform.html){target="_blank"} using `WITH_METADATA`, each record wraps an event in `Data` alongside `SystemMetadata` and optional `Metadata`. -These schemas validate standard EventBridge events inside `Data`, such as those published with `PutEvents`; arbitrary `PutRawEvents` payloads require your own schema. - -`EventBridgeWithMetadataSchema` and `EventBridgeWithMetadataEnvelope` accept nonempty arrays. The envelope returns an array of parsed details and rejects the entire batch if any record fails validation. Error paths include the record index, for example `[1, "Data", "detail", "orderId"]`. Safe parsing returns the error and original batch on failure. - -`EventBridgeWithMetadataSystemMetadataSchema` validates delivery metadata. Only `aws:DeliveryType` (`LIVE` or `REPLAY`) is required in system metadata. Other documented fields are optional strings. -Sequence numbers stay strings, including values beyond JavaScript's safe integer range. Grouping and deduplication fields can occur on unordered deliveries too; their presence does not identify a FIFO subscriber. - -Use `EventBridgeWithMetadataRecordSchema` directly for single-object delivery with `MaxBatchSize: 1`. Extend it when you need both validated details and delivery metadata. The classic `EventBridgeSchema` and `EventBridgeEnvelope` remain available for classic event payloads. - -=== "Manual parsing and metadata" - ```typescript - --8<-- "examples/snippets/parser/eventBridgeWithMetadata.ts" - ``` - -=== "Middy.js middleware" - ```typescript - --8<-- "examples/snippets/parser/eventBridgeWithMetadataMiddy.ts" - ``` - -=== "Decorator" - ```typescript - --8<-- "examples/snippets/parser/eventBridgeWithMetadataDecorator.ts" - ``` - -Import the schemas from `/schemas` or `/schemas/eventbridge-with-metadata`, and the envelope from `/envelopes` or `/envelopes/eventbridge-with-metadata`. The inferred `EventBridgeWithMetadataSystemMetadata`, `EventBridgeWithMetadataRecord`, and `EventBridgeWithMetadataEvent` types are available from `@aws-lambda-powertools/parser/types`. +| Envelope name | Behaviour | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **ApiGatewayEnvelope** | 1. Parses data using `APIGatewayProxyEventSchema`.
2. Parses `body` key using your schema and returns it. | +| **ApiGatewayV2Envelope** | 1. Parses data using `APIGatewayProxyEventV2Schema`.
2. Parses `body` key using your schema and returns it. | +| **CloudWatchEnvelope** | 1. Parses data using `CloudwatchLogsSchema` which will base64 decode and decompress it.
2. Parses records in `message` key using your schema and return them in a list. | +| **DynamoDBStreamEnvelope** | 1. Parses data using `DynamoDBStreamSchema`.
2. Parses records in `NewImage` and `OldImage` keys using your schema.
3. Returns a list with a dictionary containing `NewImage` and `OldImage` keys | +| **EventBridgeEnvelope** | 1. Parses data using `EventBridgeSchema`.
2. Parses `detail` key using your schema and returns it. | +| **EventBridgeWithMetadataEnvelope** | 1. Validates a nonempty `WITH_METADATA` batch.
2. Parses each `Data.detail` using your schema and returns the results in input order. | +| **KafkaEnvelope** | 1. Parses data using `KafkaRecordSchema`.
2. Parses `value` key using your schema and returns it. | +| **KinesisEnvelope** | 1. Parses data using `KinesisDataStreamSchema` which will base64 decode it.
2. Parses records in `Records` key using your schema and returns them in a list. | +| **KinesisFirehoseEnvelope** | 1. Parses data using `KinesisFirehoseSchema` which will base64 decode it.
2. Parses records in `Records` key using your schema and returns them in a list. | +| **LambdaFunctionUrlEnvelope** | 1. Parses data using `LambdaFunctionUrlSchema`.
2. Parses `body` key using your schema and returns it. | +| **SnsEnvelope** | 1. Parses data using `SnsSchema`.
2. Parses records in `body` key using your schema and return them in a list. | +| **SnsSqsEnvelope** | 1. Parses data using `SqsSchema`.
2. Parses SNS records in `body` key using `SnsNotificationSchema`.
3. Parses data in `Message` key using your schema and return them in a list. | +| **SqsEnvelope** | 1. Parses data using `SqsSchema`.
2. Parses records in `body` key using your schema and return them in a list. | +| **VpcLatticeEnvelope** | 1. Parses data using `VpcLatticeSchema`.
2. Parses `value` key using your schema and returns it. | +| **VpcLatticeV2Envelope** | 1. Parses data using `VpcLatticeSchema`.
2. Parses `value` key using your schema and returns it. | ## Safe parsing diff --git a/examples/snippets/parser/eventBridgeWithMetadata.ts b/examples/snippets/parser/eventBridgeWithMetadata.ts deleted file mode 100644 index 765c6fd684..0000000000 --- a/examples/snippets/parser/eventBridgeWithMetadata.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { parse } from '@aws-lambda-powertools/parser'; -import { EventBridgeWithMetadataEnvelope } from '@aws-lambda-powertools/parser/envelopes'; -import { - EventBridgeSchema, - EventBridgeWithMetadataRecordSchema, -} from '@aws-lambda-powertools/parser/schemas'; -import { z } from 'zod'; - -const orderSchema = z.object({ orderId: z.string() }); - -// Extract details from a nonempty batch, even when it contains only one record. -export const handler = async (event: unknown) => { - const orders = parse(event, EventBridgeWithMetadataEnvelope, orderSchema); - return orders.map((order) => order.orderId); -}; - -// Return a failed result with the original event instead of throwing. -export const safeHandler = async (event: unknown) => { - const result = parse( - event, - EventBridgeWithMetadataEnvelope, - orderSchema, - true - ); - if (!result.success) { - console.error('Invalid delivery', result.error, result.originalEvent); - return; - } - return result.data; -}; - -// Extend the record schema when you also need delivery metadata. -const recordSchema = EventBridgeWithMetadataRecordSchema.extend({ - Data: EventBridgeSchema.extend({ detail: orderSchema }), -}); -const batchSchema = recordSchema.array().nonempty(); - -export const metadataHandler = async (event: unknown) => { - const records = batchSchema.parse(event); - return records.map((record) => ({ - orderId: record.Data.detail.orderId, - deliveryType: record.SystemMetadata['aws:DeliveryType'], - // Keep sequence numbers as strings to avoid precision loss. - sequence: record.SystemMetadata['aws:SequenceNumber'], - })); -}; - -// Use the record schema for single-object delivery with MaxBatchSize: 1. -export const singleRecordHandler = async (event: unknown) => - recordSchema.parse(event); diff --git a/examples/snippets/parser/eventBridgeWithMetadataDecorator.ts b/examples/snippets/parser/eventBridgeWithMetadataDecorator.ts deleted file mode 100644 index 7dea6e95cb..0000000000 --- a/examples/snippets/parser/eventBridgeWithMetadataDecorator.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { LambdaInterface } from '@aws-lambda-powertools/commons/types'; -import { parser } from '@aws-lambda-powertools/parser'; -import { EventBridgeWithMetadataEnvelope } from '@aws-lambda-powertools/parser/envelopes'; -import type { Context } from 'aws-lambda'; -import { z } from 'zod'; - -const orderSchema = z.object({ orderId: z.string() }); -type Order = z.infer; - -class Lambda implements LambdaInterface { - @parser({ schema: orderSchema, envelope: EventBridgeWithMetadataEnvelope }) - public async handler(orders: Order[], _context: Context): Promise { - return orders.map((order) => order.orderId); - } -} - -const lambda = new Lambda(); -export const handler = lambda.handler.bind(lambda); diff --git a/examples/snippets/parser/eventBridgeWithMetadataMiddy.ts b/examples/snippets/parser/eventBridgeWithMetadataMiddy.ts deleted file mode 100644 index 6147e404a0..0000000000 --- a/examples/snippets/parser/eventBridgeWithMetadataMiddy.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { EventBridgeWithMetadataEnvelope } from '@aws-lambda-powertools/parser/envelopes'; -import { parser } from '@aws-lambda-powertools/parser/middleware'; -import middy from '@middy/core'; -import { z } from 'zod'; - -const orderSchema = z.object({ orderId: z.string() }); - -export const handler = middy() - .use( - parser({ schema: orderSchema, envelope: EventBridgeWithMetadataEnvelope }) - ) - .handler(async (orders) => orders.map((order) => order.orderId)); diff --git a/packages/parser/tests/unit/envelopes/eventbridge-with-metadata.test.ts b/packages/parser/tests/unit/envelopes/eventbridge-with-metadata.test.ts index e71fface2b..74a4169b26 100644 --- a/packages/parser/tests/unit/envelopes/eventbridge-with-metadata.test.ts +++ b/packages/parser/tests/unit/envelopes/eventbridge-with-metadata.test.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { EventBridgeWithMetadataEnvelope } from '../../../src/envelopes/eventbridge-with-metadata.js'; import { ParseError } from '../../../src/errors.js'; import { parse } from '../../../src/parser.js'; -import { makeRecord } from '../helpers/eventbridge-with-metadata.js'; +import { makeEventBridgeWithMetadataRecord } from '../helpers/utils.js'; describe('Envelope: EventBridge WITH_METADATA', () => { const schema = z.object({ orderId: z.string().transform(Number) }); @@ -11,7 +11,7 @@ describe('Envelope: EventBridge WITH_METADATA', () => { it.each([1, 3])('parses and transforms %i records in order', (count) => { // Prepare const event = Array.from({ length: count }, (_, index) => - makeRecord({ orderId: `${index}` }) + makeEventBridgeWithMetadataRecord({ orderId: `${index}` }) ); const expected = Array.from({ length: count }, (_, index) => ({ orderId: index, @@ -35,26 +35,45 @@ describe('Envelope: EventBridge WITH_METADATA', () => { it.each([ { name: 'invalid detail', - event: [makeRecord({ orderId: '1' }), makeRecord({ orderId: 2 })], + event: [ + makeEventBridgeWithMetadataRecord({ orderId: '1' }), + makeEventBridgeWithMetadataRecord({ orderId: 2 }), + ], path: [1, 'Data', 'detail', 'orderId'], }, { name: 'invalid metadata', - event: [{ ...makeRecord({ orderId: '1' }), SystemMetadata: {} }], + event: [ + { + ...makeEventBridgeWithMetadataRecord({ orderId: '1' }), + SystemMetadata: {}, + }, + ], path: [0, 'SystemMetadata', 'aws:DeliveryType'], }, { name: 'invalid producer metadata', - event: [{ ...makeRecord({ orderId: '1' }), Metadata: { tenant: 123 } }], + event: [ + { + ...makeEventBridgeWithMetadataRecord({ orderId: '1' }), + Metadata: { tenant: 123 }, + }, + ], path: [0, 'Metadata', 'tenant'], }, { name: 'invalid data', - event: [{ ...makeRecord({ orderId: '1' }), Data: null }], + event: [ + { ...makeEventBridgeWithMetadataRecord({ orderId: '1' }), Data: null }, + ], path: [0, 'Data'], }, { name: 'empty batch', event: [], path: [] }, - { name: 'single object', event: makeRecord({ orderId: '1' }), path: [] }, + { + name: 'single object', + event: makeEventBridgeWithMetadataRecord({ orderId: '1' }), + path: [], + }, ])( 'fails the whole batch for $name and preserves the issue path', ({ event, path }) => { @@ -85,7 +104,10 @@ describe('Envelope: EventBridge WITH_METADATA', () => { it('retains validation errors for multiple invalid records', () => { // Prepare - const event = [makeRecord({}), makeRecord({})]; + const event = [ + makeEventBridgeWithMetadataRecord({}), + makeEventBridgeWithMetadataRecord({}), + ]; // Act const result = EventBridgeWithMetadataEnvelope.safeParse(event, schema); @@ -108,7 +130,7 @@ describe('Envelope: EventBridge WITH_METADATA', () => { 'preserves a thrown transform cause %s', (cause) => { // Prepare - const event = [makeRecord({ orderId: '1' })]; + const event = [makeEventBridgeWithMetadataRecord({ orderId: '1' })]; const throwingSchema = z.unknown().transform(() => { throw cause; }); diff --git a/packages/parser/tests/unit/eventbridge-with-metadata-integration.test.ts b/packages/parser/tests/unit/eventbridge-with-metadata-integration.test.ts index 7166ee6803..8a023ad212 100644 --- a/packages/parser/tests/unit/eventbridge-with-metadata-integration.test.ts +++ b/packages/parser/tests/unit/eventbridge-with-metadata-integration.test.ts @@ -7,7 +7,7 @@ import { EventBridgeWithMetadataEnvelope } from '../../src/envelopes/index.js'; import { parser } from '../../src/index.js'; import { parser as parserMiddleware } from '../../src/middleware/index.js'; import type { ParsedResult } from '../../src/types/index.js'; -import { makeRecord } from './helpers/eventbridge-with-metadata.js'; +import { makeEventBridgeWithMetadataRecord } from './helpers/utils.js'; describe('Integration: EventBridge WITH_METADATA', () => { const schema = z.object({ orderId: z.string().transform(Number) }); @@ -23,7 +23,7 @@ describe('Integration: EventBridge WITH_METADATA', () => { } // Invoke through the Lambda boundary, which receives the unparsed event. const lambda: LambdaInterface = new Lambda(); - const event = [makeRecord({ orderId: '42' })]; + const event = [makeEventBridgeWithMetadataRecord({ orderId: '42' })]; // Act const result = await lambda.handler(event, {} as Context, () => {}); @@ -39,7 +39,7 @@ describe('Integration: EventBridge WITH_METADATA', () => { parserMiddleware({ schema, envelope: EventBridgeWithMetadataEnvelope }) ) .handler(async (event) => event); - const event = [makeRecord({ orderId: '42' })]; + const event = [makeEventBridgeWithMetadataRecord({ orderId: '42' })]; // Act const result = await handler(event, {} as Context, () => {}); @@ -68,7 +68,7 @@ describe('Integration: EventBridge WITH_METADATA', () => { } } const lambda: LambdaInterface = new Lambda(); - const event = [makeRecord({})]; + const event = [makeEventBridgeWithMetadataRecord({})]; // Act const result = await lambda.handler(event, {} as Context, () => {}); @@ -96,7 +96,7 @@ describe('Integration: EventBridge WITH_METADATA', () => { }) ) .handler(async (event) => event); - const event = [makeRecord({})]; + const event = [makeEventBridgeWithMetadataRecord({})]; // Act const result = await handler(event, {} as Context, () => {}); diff --git a/packages/parser/tests/unit/helpers/eventbridge-with-metadata.ts b/packages/parser/tests/unit/helpers/eventbridge-with-metadata.ts deleted file mode 100644 index 1761f3da9e..0000000000 --- a/packages/parser/tests/unit/helpers/eventbridge-with-metadata.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { - EventBridgeEvent, - EventBridgeWithMetadataRecord, -} from '../../../src/types/index.js'; -import { getTestEvent } from './utils.js'; - -/** - * Creates a synthetic WITH_METADATA record from the classic EventBridge fixture. - * - * @param detail - the application payload - */ -const makeRecord = (detail: unknown): EventBridgeWithMetadataRecord => ({ - Data: { - ...getTestEvent({ - eventsPath: 'eventbridge', - filename: 'base', - }), - detail, - }, - Metadata: {}, - SystemMetadata: { - 'aws:DeliveryType': 'LIVE', - 'aws:EventId': 'bus-event-id', - 'aws:SequenceNumber': '10000000000000003000', - EventGroupId: 'group-1', - DeduplicationId: 'dedup-1', - }, -}); - -export { makeRecord }; diff --git a/packages/parser/tests/unit/helpers/utils.ts b/packages/parser/tests/unit/helpers/utils.ts index b319b78f99..e78ec11f43 100644 --- a/packages/parser/tests/unit/helpers/utils.ts +++ b/packages/parser/tests/unit/helpers/utils.ts @@ -1,5 +1,9 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import type { + EventBridgeEvent, + EventBridgeWithMetadataRecord, +} from '../../../src/types/index.js'; /** * Reads and parses a JSON file from the specified events path and filename, returning the parsed object. @@ -44,4 +48,29 @@ const omit = , Keys extends keyof T>( return result; }; -export { getTestEvent, omit }; +/** + * Creates a synthetic WITH_METADATA record from the classic EventBridge fixture. + * + * @param detail - the application payload + */ +const makeEventBridgeWithMetadataRecord = ( + detail: unknown +): EventBridgeWithMetadataRecord => ({ + Data: { + ...getTestEvent({ + eventsPath: 'eventbridge', + filename: 'base', + }), + detail, + }, + Metadata: {}, + SystemMetadata: { + 'aws:DeliveryType': 'LIVE', + 'aws:EventId': 'bus-event-id', + 'aws:SequenceNumber': '10000000000000003000', + EventGroupId: 'group-1', + DeduplicationId: 'dedup-1', + }, +}); + +export { getTestEvent, makeEventBridgeWithMetadataRecord, omit }; diff --git a/packages/parser/tests/unit/schema/eventbridge-with-metadata.test.ts b/packages/parser/tests/unit/schema/eventbridge-with-metadata.test.ts index b31a946f80..882fa8838a 100644 --- a/packages/parser/tests/unit/schema/eventbridge-with-metadata.test.ts +++ b/packages/parser/tests/unit/schema/eventbridge-with-metadata.test.ts @@ -6,14 +6,13 @@ import { EventBridgeWithMetadataSchema, EventBridgeWithMetadataSystemMetadataSchema, } from '../../../src/schemas/eventbridge-with-metadata.js'; -import { makeRecord } from '../helpers/eventbridge-with-metadata.js'; -import { omit } from '../helpers/utils.js'; +import { makeEventBridgeWithMetadataRecord, omit } from '../helpers/utils.js'; describe('Schema: EventBridge WITH_METADATA', () => { it.each([1, 3])('parses a batch of %i records in order', (count) => { // Prepare const event = Array.from({ length: count }, (_, index) => - makeRecord({ orderId: `order-${index}` }) + makeEventBridgeWithMetadataRecord({ orderId: `order-${index}` }) ); // Act @@ -27,7 +26,7 @@ describe('Schema: EventBridge WITH_METADATA', () => { 'preserves sequence %s exactly', (sequence) => { // Prepare - const event = makeRecord({ orderId: '1' }); + const event = makeEventBridgeWithMetadataRecord({ orderId: '1' }); event.SystemMetadata['aws:SequenceNumber'] = sequence; // Act @@ -44,7 +43,7 @@ describe('Schema: EventBridge WITH_METADATA', () => { (deliveryType) => { // Prepare: synthetic omission case based on documented optionality. const event = { - ...makeRecord({ orderId: '1' }), + ...makeEventBridgeWithMetadataRecord({ orderId: '1' }), SystemMetadata: { 'aws:DeliveryType': deliveryType }, }; @@ -60,7 +59,7 @@ describe('Schema: EventBridge WITH_METADATA', () => { it('retains replay names and independent event identifiers', () => { // Prepare - const event = makeRecord({ orderId: '1' }); + const event = makeEventBridgeWithMetadataRecord({ orderId: '1' }); event.Data['replay-name'] = 'orders-replay'; event.SystemMetadata['aws:DeliveryType'] = 'REPLAY'; @@ -75,7 +74,10 @@ describe('Schema: EventBridge WITH_METADATA', () => { 'accepts optional producer metadata %j', (metadata) => { // Prepare - const record = omit(['Metadata'], makeRecord({ orderId: '1' })); + const record = omit( + ['Metadata'], + makeEventBridgeWithMetadataRecord({ orderId: '1' }) + ); const event = metadata === undefined ? record : { ...record, Metadata: metadata }; @@ -87,16 +89,20 @@ describe('Schema: EventBridge WITH_METADATA', () => { } ); - it.each([[], {}, null, undefined, 'event', makeRecord({ orderId: '1' })])( - 'rejects a non-batch input %j', - (event) => { - // Act - const result = EventBridgeWithMetadataSchema.safeParse(event); + it.each([ + [], + {}, + null, + undefined, + 'event', + makeEventBridgeWithMetadataRecord({ orderId: '1' }), + ])('rejects a non-batch input %j', (event) => { + // Act + const result = EventBridgeWithMetadataSchema.safeParse(event); - // Assess - expect(result.success).toBe(false); - } - ); + // Assess + expect(result.success).toBe(false); + }); it.each([ undefined, @@ -109,7 +115,7 @@ describe('Schema: EventBridge WITH_METADATA', () => { ])('rejects invalid system metadata %j', (systemMetadata) => { // Prepare const event = { - ...makeRecord({ orderId: '1' }), + ...makeEventBridgeWithMetadataRecord({ orderId: '1' }), SystemMetadata: systemMetadata, }; @@ -150,7 +156,10 @@ describe('Schema: EventBridge WITH_METADATA', () => { 'rejects invalid producer metadata %j', (metadata) => { // Prepare - const event = { ...makeRecord({ orderId: '1' }), Metadata: metadata }; + const event = { + ...makeEventBridgeWithMetadataRecord({ orderId: '1' }), + Metadata: metadata, + }; // Act const result = EventBridgeWithMetadataRecordSchema.safeParse(event); @@ -164,11 +173,14 @@ describe('Schema: EventBridge WITH_METADATA', () => { null, undefined, { orderId: 'raw-event' }, - { ...makeRecord({}).Data, time: 'invalid' }, - omit(['detail-type'], makeRecord({}).Data), + { ...makeEventBridgeWithMetadataRecord({}).Data, time: 'invalid' }, + omit(['detail-type'], makeEventBridgeWithMetadataRecord({}).Data), ])('rejects malformed standard event data %j', (data) => { // Prepare - const event = { ...makeRecord({ orderId: '1' }), Data: data }; + const event = { + ...makeEventBridgeWithMetadataRecord({ orderId: '1' }), + Data: data, + }; // Act const result = EventBridgeWithMetadataRecordSchema.safeParse(event); @@ -184,7 +196,7 @@ describe('Schema: EventBridge WITH_METADATA', () => { detail: z.object({ orderId: z.string().transform(Number) }), }), }); - const event = makeRecord({ orderId: '42' }); + const event = makeEventBridgeWithMetadataRecord({ orderId: '42' }); event.Metadata = { tenant: 'example' }; // Act From 3ed0da0cd5724541faf79c0d0a410ad73c87a64b Mon Sep 17 00:00:00 2001 From: Vidit Shah Date: Mon, 5 Oct 2026 01:33:36 +0530 Subject: [PATCH 3/3] test(parser): consolidate WITH_METADATA integration coverage --- ...ntbridge-with-metadata-integration.test.ts | 111 ------------------ .../tests/unit/parser.decorator.test.ts | 60 +++++++++- .../parser/tests/unit/parser.middy.test.ts | 51 +++++++- 3 files changed, 108 insertions(+), 114 deletions(-) delete mode 100644 packages/parser/tests/unit/eventbridge-with-metadata-integration.test.ts diff --git a/packages/parser/tests/unit/eventbridge-with-metadata-integration.test.ts b/packages/parser/tests/unit/eventbridge-with-metadata-integration.test.ts deleted file mode 100644 index 8a023ad212..0000000000 --- a/packages/parser/tests/unit/eventbridge-with-metadata-integration.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { LambdaInterface } from '@aws-lambda-powertools/commons/types'; -import middy from '@middy/core'; -import type { Context, Handler } from 'aws-lambda'; -import { describe, expect, it } from 'vitest'; -import { z } from 'zod'; -import { EventBridgeWithMetadataEnvelope } from '../../src/envelopes/index.js'; -import { parser } from '../../src/index.js'; -import { parser as parserMiddleware } from '../../src/middleware/index.js'; -import type { ParsedResult } from '../../src/types/index.js'; -import { makeEventBridgeWithMetadataRecord } from './helpers/utils.js'; - -describe('Integration: EventBridge WITH_METADATA', () => { - const schema = z.object({ orderId: z.string().transform(Number) }); - type Order = z.infer; - - it('passes transformed detail arrays through the decorator', async () => { - // Prepare - class Lambda implements LambdaInterface { - @parser({ schema, envelope: EventBridgeWithMetadataEnvelope }) - public async handler(event: Order[], _context: Context) { - return event; - } - } - // Invoke through the Lambda boundary, which receives the unparsed event. - const lambda: LambdaInterface = new Lambda(); - const event = [makeEventBridgeWithMetadataRecord({ orderId: '42' })]; - - // Act - const result = await lambda.handler(event, {} as Context, () => {}); - - // Assess - expect(result).toEqual([{ orderId: 42 }]); - }); - - it('passes transformed detail arrays through middleware', async () => { - // Prepare - const handler: Handler = middy() - .use( - parserMiddleware({ schema, envelope: EventBridgeWithMetadataEnvelope }) - ) - .handler(async (event) => event); - const event = [makeEventBridgeWithMetadataRecord({ orderId: '42' })]; - - // Act - const result = await handler(event, {} as Context, () => {}); - - // Assess - expect(result).toEqual([{ orderId: 42 }]); - }); - - it('passes a failed result through the decorator for a throwing transform', async () => { - // Prepare - const cause = new SyntaxError('invalid JSON'); - const throwingSchema = z.unknown().transform(() => { - throw cause; - }); - class Lambda implements LambdaInterface { - @parser({ - schema: throwingSchema, - envelope: EventBridgeWithMetadataEnvelope, - safeParse: true, - }) - public async handler( - event: ParsedResult, - _context: Context - ) { - return event; - } - } - const lambda: LambdaInterface = new Lambda(); - const event = [makeEventBridgeWithMetadataRecord({})]; - - // Act - const result = await lambda.handler(event, {} as Context, () => {}); - - // Assess - expect(result).toMatchObject({ - success: false, - originalEvent: event, - error: { cause }, - }); - }); - - it('passes a failed result through middleware for a throwing transform', async () => { - // Prepare - const cause = new SyntaxError('invalid JSON'); - const throwingSchema = z.unknown().transform(() => { - throw cause; - }); - const handler: Handler = middy() - .use( - parserMiddleware({ - schema: throwingSchema, - envelope: EventBridgeWithMetadataEnvelope, - safeParse: true, - }) - ) - .handler(async (event) => event); - const event = [makeEventBridgeWithMetadataRecord({})]; - - // Act - const result = await handler(event, {} as Context, () => {}); - - // Assess - expect(result).toMatchObject({ - success: false, - originalEvent: event, - error: { cause }, - }); - }); -}); diff --git a/packages/parser/tests/unit/parser.decorator.test.ts b/packages/parser/tests/unit/parser.decorator.test.ts index 193f8e2340..b39fb9981f 100644 --- a/packages/parser/tests/unit/parser.decorator.test.ts +++ b/packages/parser/tests/unit/parser.decorator.test.ts @@ -3,11 +3,15 @@ import type { Context } from 'aws-lambda'; 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 { ParseError } from '../../src/errors.js'; import { parser } from '../../src/index.js'; import { EventBridgeSchema } from '../../src/schemas/index.js'; import type { EventBridgeEvent, ParsedResult } from '../../src/types/index.js'; -import { getTestEvent } from './helpers/utils.js'; +import { + getTestEvent, + makeEventBridgeWithMetadataRecord, +} from './helpers/utils.js'; describe('Decorator: parser', () => { const schema = z.object({ @@ -368,4 +372,58 @@ describe('Decorator: parser', () => { ) ).toThrow(TypeError); }); + + it('parses transformed WITH_METADATA details with the decorator', async () => { + // Prepare + const schema = z.object({ orderId: z.string().transform(Number) }); + type Order = z.infer; + class Lambda implements LambdaInterface { + @parser({ schema, envelope: EventBridgeWithMetadataEnvelope }) + public async handler(event: Order[], _context: Context) { + return event; + } + } + // Invoke through the Lambda boundary, which receives the unparsed event. + const lambda: LambdaInterface = new Lambda(); + const event = [makeEventBridgeWithMetadataRecord({ orderId: '42' })]; + + // Act + const result = await lambda.handler(event, {} as Context, () => {}); + + // Assess + expect(result).toEqual([{ orderId: 42 }]); + }); + + it('returns a failed WITH_METADATA result for a throwing transform', async () => { + // Prepare + const cause = new SyntaxError('invalid JSON'); + const throwingSchema = z.unknown().transform(() => { + throw cause; + }); + class Lambda implements LambdaInterface { + @parser({ + schema: throwingSchema, + envelope: EventBridgeWithMetadataEnvelope, + safeParse: true, + }) + public async handler( + event: ParsedResult, + _context: Context + ) { + return event; + } + } + const lambda: LambdaInterface = new Lambda(); + const event = [makeEventBridgeWithMetadataRecord({})]; + + // Act + const result = await lambda.handler(event, {} as Context, () => {}); + + // Assess + expect(result).toMatchObject({ + success: false, + originalEvent: event, + error: { cause }, + }); + }); }); diff --git a/packages/parser/tests/unit/parser.middy.test.ts b/packages/parser/tests/unit/parser.middy.test.ts index 0796a27b64..d050bab9e7 100644 --- a/packages/parser/tests/unit/parser.middy.test.ts +++ b/packages/parser/tests/unit/parser.middy.test.ts @@ -1,8 +1,9 @@ import middy from '@middy/core'; -import type { Context } from 'aws-lambda'; +import type { Context, Handler } from 'aws-lambda'; 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 { SqsEnvelope } from '../../src/envelopes/sqs.js'; import { ParseError } from '../../src/errors.js'; import { parser } from '../../src/middleware/index.js'; @@ -11,7 +12,10 @@ import type { ParsedResult, SqsEvent, } from '../../src/types/index.js'; -import { getTestEvent } from './helpers/utils.js'; +import { + getTestEvent, + makeEventBridgeWithMetadataRecord, +} from './helpers/utils.js'; describe('Middleware: parser', () => { const schema = z @@ -407,4 +411,47 @@ describe('Middleware: parser', () => { }); expect(otherOnError).toHaveBeenCalledTimes(1); }); + + it('parses transformed WITH_METADATA details with middleware', async () => { + // Prepare + const schema = z.object({ orderId: z.string().transform(Number) }); + const handler: Handler = middy() + .use(parser({ schema, envelope: EventBridgeWithMetadataEnvelope })) + .handler(async (event) => event); + const event = [makeEventBridgeWithMetadataRecord({ orderId: '42' })]; + + // Act + const result = await handler(event, {} as Context, () => {}); + + // Assess + expect(result).toEqual([{ orderId: 42 }]); + }); + + it('returns a failed WITH_METADATA result for a throwing transform', async () => { + // Prepare + const cause = new SyntaxError('invalid JSON'); + const throwingSchema = z.unknown().transform(() => { + throw cause; + }); + const handler: Handler = middy() + .use( + parser({ + schema: throwingSchema, + envelope: EventBridgeWithMetadataEnvelope, + safeParse: true, + }) + ) + .handler(async (event) => event); + const event = [makeEventBridgeWithMetadataRecord({})]; + + // Act + const result = await handler(event, {} as Context, () => {}); + + // Assess + expect(result).toMatchObject({ + success: false, + originalEvent: event, + error: { cause }, + }); + }); });