diff --git a/docs/features/parser.md b/docs/features/parser.md index 89efc57165..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,22 +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. | +| 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/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..74a4169b26 --- /dev/null +++ b/packages/parser/tests/unit/envelopes/eventbridge-with-metadata.test.ts @@ -0,0 +1,158 @@ +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 { makeEventBridgeWithMetadataRecord } from '../helpers/utils.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) => + makeEventBridgeWithMetadataRecord({ 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: [ + makeEventBridgeWithMetadataRecord({ orderId: '1' }), + makeEventBridgeWithMetadataRecord({ orderId: 2 }), + ], + path: [1, 'Data', 'detail', 'orderId'], + }, + { + name: 'invalid metadata', + event: [ + { + ...makeEventBridgeWithMetadataRecord({ orderId: '1' }), + SystemMetadata: {}, + }, + ], + path: [0, 'SystemMetadata', 'aws:DeliveryType'], + }, + { + name: 'invalid producer metadata', + event: [ + { + ...makeEventBridgeWithMetadataRecord({ orderId: '1' }), + Metadata: { tenant: 123 }, + }, + ], + path: [0, 'Metadata', 'tenant'], + }, + { + name: 'invalid data', + event: [ + { ...makeEventBridgeWithMetadataRecord({ orderId: '1' }), Data: null }, + ], + path: [0, 'Data'], + }, + { name: 'empty batch', event: [], path: [] }, + { + name: 'single object', + event: makeEventBridgeWithMetadataRecord({ 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 = [ + makeEventBridgeWithMetadataRecord({}), + makeEventBridgeWithMetadataRecord({}), + ]; + + // 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 = [makeEventBridgeWithMetadataRecord({ 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/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/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 }, + }); + }); }); 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..882fa8838a --- /dev/null +++ b/packages/parser/tests/unit/schema/eventbridge-with-metadata.test.ts @@ -0,0 +1,210 @@ +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 { 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) => + makeEventBridgeWithMetadataRecord({ 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 = makeEventBridgeWithMetadataRecord({ 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 = { + ...makeEventBridgeWithMetadataRecord({ 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 = makeEventBridgeWithMetadataRecord({ 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'], + makeEventBridgeWithMetadataRecord({ 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', + makeEventBridgeWithMetadataRecord({ 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 = { + ...makeEventBridgeWithMetadataRecord({ 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 = { + ...makeEventBridgeWithMetadataRecord({ orderId: '1' }), + Metadata: metadata, + }; + + // Act + const result = EventBridgeWithMetadataRecordSchema.safeParse(event); + + // Assess + expect(result.success).toBe(false); + } + ); + + it.each([ + null, + undefined, + { orderId: 'raw-event' }, + { ...makeEventBridgeWithMetadataRecord({}).Data, time: 'invalid' }, + omit(['detail-type'], makeEventBridgeWithMetadataRecord({}).Data), + ])('rejects malformed standard event data %j', (data) => { + // Prepare + const event = { + ...makeEventBridgeWithMetadataRecord({ 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 = makeEventBridgeWithMetadataRecord({ 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); + }); +});