From a365f8ad7c61b65ad2ffd12d27b2cbb23d9386bf Mon Sep 17 00:00:00 2001 From: Alexander Schueren Date: Tue, 19 Dec 2023 16:05:12 +0100 Subject: [PATCH 1/5] add middy middleware --- .../{eventbridge.ts => event-bridge.ts} | 0 packages/parser/src/middleware/parser.ts | 56 ++++++++ packages/parser/src/types/envelope.ts | 30 +++++ .../tests/unit/envelopes/eventbridge.test.ts | 2 +- packages/parser/tests/unit/parser.test.ts | 127 ++++++++++++++++++ 5 files changed, 214 insertions(+), 1 deletion(-) rename packages/parser/src/envelopes/{eventbridge.ts => event-bridge.ts} (100%) create mode 100644 packages/parser/src/middleware/parser.ts create mode 100644 packages/parser/src/types/envelope.ts create mode 100644 packages/parser/tests/unit/parser.test.ts diff --git a/packages/parser/src/envelopes/eventbridge.ts b/packages/parser/src/envelopes/event-bridge.ts similarity index 100% rename from packages/parser/src/envelopes/eventbridge.ts rename to packages/parser/src/envelopes/event-bridge.ts diff --git a/packages/parser/src/middleware/parser.ts b/packages/parser/src/middleware/parser.ts new file mode 100644 index 000000000..20f93c673 --- /dev/null +++ b/packages/parser/src/middleware/parser.ts @@ -0,0 +1,56 @@ +import { MiddyLikeRequest } from '@aws-lambda-powertools/commons/types'; +import { MiddlewareObj } from '@middy/core'; +import { ZodSchema } from 'zod'; +import { Envelope } from '../types/envelope.js'; + +interface ParserOptions { + schema: S; + envelope?: Envelope; +} + +/** + * A middiy middleware to parse your event. + * + * @exmaple + * ```typescirpt + * import { parser } from '@aws-lambda-powertools/parser/middleware'; + * import middy from '@middy/core'; + * import { sqsEnvelope } from '@aws-lambda-powertools/parser/envelopes/sqs;' + * + * const oderSchema = z.object({ + * id: z.number(), + * description: z.string(), + * quantity: z.number(), + * }); + * + * type Order = z.infer; + * + * export const handler = middy( + * async (event: Order, _context: unknown): Promise => { + * // event is validated as sqs message envelope + * // the body is unwrapped and parsed into object ready to use + * // you can now use event as Order in your code + * } + * ).use(parser({ schema: oderSchema, envelope: sqsEnvelope })); + * ``` + * + * @param options + */ +const parser = ( + options: ParserOptions +): MiddlewareObj => { + const before = (request: MiddyLikeRequest): void => { + const { schema, envelope } = options; + if (envelope) { + request.event = envelope(request.event, schema); + } else { + request.event = schema.parse(request.event); + } + }; + + return { + before, + }; +}; + +export { parser }; diff --git a/packages/parser/src/types/envelope.ts b/packages/parser/src/types/envelope.ts new file mode 100644 index 000000000..3bbc227f5 --- /dev/null +++ b/packages/parser/src/types/envelope.ts @@ -0,0 +1,30 @@ +import { apiGatewayEnvelope } from '../envelopes/apigw.js'; +import { apiGatewayV2Envelope } from '../envelopes/apigwv2.js'; +import { cloudWatchEnvelope } from '../envelopes/cloudwatch.js'; +import { dynamoDDStreamEnvelope } from '../envelopes/dynamodb.js'; +import { kafkaEnvelope } from '../envelopes/kafka.js'; +import { kinesisEnvelope } from '../envelopes/kinesis.js'; +import { kinesisFirehoseEnvelope } from '../envelopes/kinesis-firehose.js'; +import { lambdaFunctionUrlEnvelope } from '../envelopes/lambda.js'; +import { snsEnvelope } from '../envelopes/sns.js'; +import { snsSqsEnvelope } from '../envelopes/sns.js'; +import { sqsEnvelope } from '../envelopes/sqs.js'; +import { vpcLatticeEnvelope } from '../envelopes/vpc-lattice.js'; +import { vpcLatticeV2Envelope } from '../envelopes/vpc-latticev2.js'; +import { eventBridgeEnvelope } from '../envelopes/event-bridge.js'; + +export type Envelope = + | typeof apiGatewayEnvelope + | typeof apiGatewayV2Envelope + | typeof cloudWatchEnvelope + | typeof dynamoDDStreamEnvelope + | typeof eventBridgeEnvelope + | typeof kafkaEnvelope + | typeof kinesisEnvelope + | typeof kinesisFirehoseEnvelope + | typeof lambdaFunctionUrlEnvelope + | typeof snsEnvelope + | typeof snsSqsEnvelope + | typeof sqsEnvelope + | typeof vpcLatticeEnvelope + | typeof vpcLatticeV2Envelope; diff --git a/packages/parser/tests/unit/envelopes/eventbridge.test.ts b/packages/parser/tests/unit/envelopes/eventbridge.test.ts index ee8c62a95..8212e77d9 100644 --- a/packages/parser/tests/unit/envelopes/eventbridge.test.ts +++ b/packages/parser/tests/unit/envelopes/eventbridge.test.ts @@ -7,7 +7,7 @@ import { TestEvents, TestSchema } from '../schema/utils.js'; import { generateMock } from '@anatine/zod-mock'; import { EventBridgeEvent } from 'aws-lambda'; -import { eventBridgeEnvelope } from '../../../src/envelopes/eventbridge.js'; +import { eventBridgeEnvelope } from '../../../src/envelopes/event-bridge.js'; describe('EventBridgeEnvelope ', () => { it('should parse eventbridge event', () => { diff --git a/packages/parser/tests/unit/parser.test.ts b/packages/parser/tests/unit/parser.test.ts new file mode 100644 index 000000000..19bbfdb52 --- /dev/null +++ b/packages/parser/tests/unit/parser.test.ts @@ -0,0 +1,127 @@ +/** + * Test middelware parser + * + * @group unit/parser + */ + +import middy from '@middy/core'; +import { Context } from 'aws-lambda'; +import { parser } from '../../src/middleware/parser.js'; +import { generateMock } from '@anatine/zod-mock'; +import { SqsSchema } from '../../src/schemas/sqs.js'; +import { z, ZodSchema } from 'zod'; +import { sqsEnvelope } from '../../src/envelopes/sqs'; +import { TestSchema } from './schema/utils'; + +describe('Middleware: parser', () => { + const schema = z.object({ + name: z.string(), + age: z.number().min(18).max(99), + }); + type schema = z.infer; + const handler = async ( + event: schema | unknown, + _context: Context + ): Promise => { + return event; + }; + + describe(' when envelope is provided ', () => { + const middyfiedHandler = middy(handler).use( + parser({ schema: TestSchema, envelope: sqsEnvelope }) + ); + + it('should parse request body with schema and envelope', async () => { + const bodyMock = generateMock(TestSchema); + parser({ schema: schema, envelope: sqsEnvelope }); + + const event = generateMock(SqsSchema, { + stringMap: { + body: () => JSON.stringify(bodyMock), + }, + }); + + const result = (await middyfiedHandler(event, {} as Context)) as schema[]; + result.forEach((item) => { + expect(item).toEqual(bodyMock); + }); + }); + + it('should throw when envelope does not match', async () => { + await expect(async () => { + await middyfiedHandler({ name: 'John', age: 18 }, {} as Context); + }).rejects.toThrowError(); + }); + + it('should throw when schema does not match', async () => { + const event = generateMock(SqsSchema, { + stringMap: { + body: () => '42', + }, + }); + + await expect(middyfiedHandler(event, {} as Context)).rejects.toThrow(); + }); + it('should throw when provided schema is invalid', async () => { + const middyfiedHandler = middy(handler).use( + parser({ schema: {} as ZodSchema, envelope: sqsEnvelope }) + ); + + await expect(middyfiedHandler(42, {} as Context)).rejects.toThrowError(); + }); + it('should throw when envelope is correct but schema is invalid', async () => { + const event = generateMock(SqsSchema, { + stringMap: { + body: () => JSON.stringify({ name: 'John', foo: 'bar' }), + }, + }); + + const middyfiedHandler = middy(handler).use( + parser({ schema: {} as ZodSchema, envelope: sqsEnvelope }) + ); + + await expect( + middyfiedHandler(event, {} as Context) + ).rejects.toThrowError(); + }); + }); + + describe(' when envelope is not provided', () => { + it('should parse the event with built-in schema', async () => { + const event = generateMock(SqsSchema); + + const middyfiedHandler = middy(handler).use( + parser({ schema: SqsSchema }) + ); + + expect(await middyfiedHandler(event, {} as Context)).toEqual(event); + }); + + it('should parse custom event', async () => { + const event = { name: 'John', age: 18 }; + const middyfiedHandler = middy(handler).use( + parser({ schema: TestSchema }) + ); + + expect(await middyfiedHandler(event, {} as Context)).toEqual(event); + }); + + it('should throw when the schema does not match', async () => { + const middyfiedHandler = middy(handler).use( + parser({ schema: TestSchema }) + ); + + await expect(middyfiedHandler(42, {} as Context)).rejects.toThrow(); + }); + + it('should throw when provided schema is invalid', async () => { + const middyfiedHandler = middy(handler).use( + parser({ schema: {} as ZodSchema }) + ); + + await expect( + middyfiedHandler({ foo: 'bar' }, {} as Context) + ).rejects.toThrowError(); + }); + }); +}); From 2260f5b95e3f59f051c119bf4a1a1221873882aa Mon Sep 17 00:00:00 2001 From: Alexander Schueren Date: Wed, 20 Dec 2023 07:10:07 +0100 Subject: [PATCH 2/5] add type to imports --- packages/parser/src/middleware/parser.ts | 10 ++++----- packages/parser/src/types/envelope.ts | 28 ++++++++++++------------ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/parser/src/middleware/parser.ts b/packages/parser/src/middleware/parser.ts index 20f93c673..2a79ad0a3 100644 --- a/packages/parser/src/middleware/parser.ts +++ b/packages/parser/src/middleware/parser.ts @@ -1,7 +1,7 @@ -import { MiddyLikeRequest } from '@aws-lambda-powertools/commons/types'; -import { MiddlewareObj } from '@middy/core'; -import { ZodSchema } from 'zod'; -import { Envelope } from '../types/envelope.js'; +import { type MiddyLikeRequest } from '@aws-lambda-powertools/commons/types'; +import { type MiddlewareObj } from '@middy/core'; +import { type ZodSchema } from 'zod'; +import { type Envelope } from '../types/envelope.js'; interface ParserOptions { schema: S; @@ -12,7 +12,7 @@ interface ParserOptions { * A middiy middleware to parse your event. * * @exmaple - * ```typescirpt + * ```typescript * import { parser } from '@aws-lambda-powertools/parser/middleware'; * import middy from '@middy/core'; * import { sqsEnvelope } from '@aws-lambda-powertools/parser/envelopes/sqs;' diff --git a/packages/parser/src/types/envelope.ts b/packages/parser/src/types/envelope.ts index 3bbc227f5..81d9edfcf 100644 --- a/packages/parser/src/types/envelope.ts +++ b/packages/parser/src/types/envelope.ts @@ -1,17 +1,17 @@ -import { apiGatewayEnvelope } from '../envelopes/apigw.js'; -import { apiGatewayV2Envelope } from '../envelopes/apigwv2.js'; -import { cloudWatchEnvelope } from '../envelopes/cloudwatch.js'; -import { dynamoDDStreamEnvelope } from '../envelopes/dynamodb.js'; -import { kafkaEnvelope } from '../envelopes/kafka.js'; -import { kinesisEnvelope } from '../envelopes/kinesis.js'; -import { kinesisFirehoseEnvelope } from '../envelopes/kinesis-firehose.js'; -import { lambdaFunctionUrlEnvelope } from '../envelopes/lambda.js'; -import { snsEnvelope } from '../envelopes/sns.js'; -import { snsSqsEnvelope } from '../envelopes/sns.js'; -import { sqsEnvelope } from '../envelopes/sqs.js'; -import { vpcLatticeEnvelope } from '../envelopes/vpc-lattice.js'; -import { vpcLatticeV2Envelope } from '../envelopes/vpc-latticev2.js'; -import { eventBridgeEnvelope } from '../envelopes/event-bridge.js'; +import { type apiGatewayEnvelope } from '../envelopes/apigw.js'; +import { type apiGatewayV2Envelope } from '../envelopes/apigwv2.js'; +import { type cloudWatchEnvelope } from '../envelopes/cloudwatch.js'; +import { type dynamoDDStreamEnvelope } from '../envelopes/dynamodb.js'; +import { type kafkaEnvelope } from '../envelopes/kafka.js'; +import { type kinesisEnvelope } from '../envelopes/kinesis.js'; +import { type kinesisFirehoseEnvelope } from '../envelopes/kinesis-firehose.js'; +import { type lambdaFunctionUrlEnvelope } from '../envelopes/lambda.js'; +import { type snsEnvelope } from '../envelopes/sns.js'; +import { type snsSqsEnvelope } from '../envelopes/sns.js'; +import { type sqsEnvelope } from '../envelopes/sqs.js'; +import { type vpcLatticeEnvelope } from '../envelopes/vpc-lattice.js'; +import { type vpcLatticeV2Envelope } from '../envelopes/vpc-latticev2.js'; +import { type eventBridgeEnvelope } from '../envelopes/event-bridge.js'; export type Envelope = | typeof apiGatewayEnvelope From 98144c230440e105b6e7aeacb67348c410fa2b6f Mon Sep 17 00:00:00 2001 From: Alexander Schueren Date: Thu, 21 Dec 2023 15:58:46 +0100 Subject: [PATCH 3/5] add parser decorator --- packages/parser/src/middleware/parser.ts | 7 +- packages/parser/src/parser.ts | 59 ++++++++++++ packages/parser/src/types/ParserOptions.ts | 9 ++ .../tests/unit/parser.decorator.test.ts | 92 +++++++++++++++++++ .../{parser.test.ts => parser.middy.test.ts} | 0 5 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 packages/parser/src/parser.ts create mode 100644 packages/parser/src/types/ParserOptions.ts create mode 100644 packages/parser/tests/unit/parser.decorator.test.ts rename packages/parser/tests/unit/{parser.test.ts => parser.middy.test.ts} (100%) diff --git a/packages/parser/src/middleware/parser.ts b/packages/parser/src/middleware/parser.ts index 2a79ad0a3..8a2c26c32 100644 --- a/packages/parser/src/middleware/parser.ts +++ b/packages/parser/src/middleware/parser.ts @@ -1,12 +1,7 @@ import { type MiddyLikeRequest } from '@aws-lambda-powertools/commons/types'; import { type MiddlewareObj } from '@middy/core'; import { type ZodSchema } from 'zod'; -import { type Envelope } from '../types/envelope.js'; - -interface ParserOptions { - schema: S; - envelope?: Envelope; -} +import { type ParserOptions } from '../types/ParserOptions.js'; /** * A middiy middleware to parse your event. diff --git a/packages/parser/src/parser.ts b/packages/parser/src/parser.ts new file mode 100644 index 000000000..095d95eaa --- /dev/null +++ b/packages/parser/src/parser.ts @@ -0,0 +1,59 @@ +import { HandlerMethodDecorator } from '@aws-lambda-powertools/commons/types'; +import { Context, Handler } from 'aws-lambda'; +import { ZodSchema } from 'zod'; +import { type ParserOptions } from './types/ParserOptions.js'; + +/** + * A decorator to parse your event. + * + * @example + * ```typescript + * + * import { parser } from '@aws-lambda-powertools/parser'; + * import { sqsEnvelope } from '@aws-lambda-powertools/parser/envelopes/sqs'; + * + * + * const Order = z.object({ + * orderId: z.string(), + * description: z.string(), + * } + * + * class Lambda extends LambdaInterface { + * + * @parser({ envelope: sqsEnvelope, schema: OrderSchema }) + * public async handler(event: Order, _context: Context): Promise { + * // sqs event is parsed and the payload is extracted and parsed + * // apply business logic to your Order event + * const res = processOrder(event); + * return res; + * } + * } + * + * @param options + */ +const parser = ( + options: ParserOptions +): HandlerMethodDecorator => { + return (_target, _propertyKey, descriptor) => { + const original = descriptor.value!; + + const { schema, envelope } = options; + + descriptor.value = function ( + this: Handler, + event: unknown, + context: Context, + callback + ) { + const parsedEvent = envelope + ? envelope(event, schema) + : schema.parse(event); + + return original.call(this, parsedEvent, context, callback); + }; + + return descriptor; + }; +}; + +export { parser }; diff --git a/packages/parser/src/types/ParserOptions.ts b/packages/parser/src/types/ParserOptions.ts new file mode 100644 index 000000000..4127c45b2 --- /dev/null +++ b/packages/parser/src/types/ParserOptions.ts @@ -0,0 +1,9 @@ +import type { ZodSchema } from 'zod'; +import { Envelope } from './envelope.js'; + +interface ParserOptions { + schema: S; + envelope?: Envelope; +} + +export { type ParserOptions }; diff --git a/packages/parser/tests/unit/parser.decorator.test.ts b/packages/parser/tests/unit/parser.decorator.test.ts new file mode 100644 index 000000000..62a310979 --- /dev/null +++ b/packages/parser/tests/unit/parser.decorator.test.ts @@ -0,0 +1,92 @@ +/** + * Test decorator parser + * + * @group unit/parser + */ + +import { LambdaInterface } from '@aws-lambda-powertools/commons/lib/esm/types'; +import { Context, EventBridgeEvent } from 'aws-lambda'; +import { parser } from '../../src/parser'; +import { TestSchema, TestEvents } from './schema/utils'; +import { generateMock } from '@anatine/zod-mock'; +import { eventBridgeEnvelope } from '../../src/envelopes/event-bridge'; +import { EventBridgeSchema } from '../../src/schemas/eventbridge'; +import { z } from 'zod'; + +describe('Parser Decorator', () => { + const customEventBridgeSchema = EventBridgeSchema.extend({ + detail: TestSchema, + }); + + type TestSchema = z.infer; + + class TestClass implements LambdaInterface { + @parser({ schema: TestSchema }) + public async handler( + event: TestSchema, + _context: Context + ): Promise { + return event; + } + + @parser({ schema: customEventBridgeSchema }) + public async handlerWithCustomSchema( + event: unknown, + _context: Context + ): Promise { + return event; + } + + @parser({ envelope: eventBridgeEnvelope, schema: TestSchema }) + public async handlerWithSchemaAndEnvelope( + event: unknown, + _context: Context + ): Promise { + return event; + } + } + + const lambda = new TestClass(); + + it('should parse custom schema event', async () => { + const testEvent = generateMock(TestSchema); + + const resp = await lambda.handler(testEvent, {} as Context); + + expect(resp).toEqual(testEvent); + }); + + it('should parse custom schema with envelope event', async () => { + const customPayload = generateMock(TestSchema); + const testEvent = TestEvents.eventBridgeEvent as EventBridgeEvent< + string, + unknown + >; + testEvent.detail = customPayload; + + const resp = await lambda.handlerWithSchemaAndEnvelope( + testEvent, + {} as Context + ); + + expect(resp).toEqual(customPayload); + }); + + it('should parse extended envelope event', async () => { + const customPayload = generateMock(TestSchema); + + const testEvent = generateMock(customEventBridgeSchema); + testEvent.detail = customPayload; + + const resp: z.infer = + (await lambda.handlerWithCustomSchema( + testEvent, + {} as Context + )) as z.infer; + + expect(customEventBridgeSchema.parse(resp)).toEqual(testEvent); + expect(resp.detail).toEqual(customPayload); + }); + + it('should return original event if no schema is provided', async () => {}); +}); diff --git a/packages/parser/tests/unit/parser.test.ts b/packages/parser/tests/unit/parser.middy.test.ts similarity index 100% rename from packages/parser/tests/unit/parser.test.ts rename to packages/parser/tests/unit/parser.middy.test.ts From 3bafad7d8bd9481b9c3b27807488598c25bef951 Mon Sep 17 00:00:00 2001 From: Alexander Schueren Date: Thu, 21 Dec 2023 16:21:01 +0100 Subject: [PATCH 4/5] we can keep ParserOptions as type instead of interface --- packages/parser/src/types/ParserOptions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/parser/src/types/ParserOptions.ts b/packages/parser/src/types/ParserOptions.ts index 4127c45b2..efdf91cf9 100644 --- a/packages/parser/src/types/ParserOptions.ts +++ b/packages/parser/src/types/ParserOptions.ts @@ -1,9 +1,9 @@ import type { ZodSchema } from 'zod'; import { Envelope } from './envelope.js'; -interface ParserOptions { +type ParserOptions = { schema: S; envelope?: Envelope; -} +}; export { type ParserOptions }; From 2ed22fea2908ba91b85e9bf42e90203f79988579 Mon Sep 17 00:00:00 2001 From: Alexander Schueren Date: Thu, 21 Dec 2023 18:43:25 +0100 Subject: [PATCH 5/5] changed decorator to async function --- packages/parser/src/parser.ts | 2 +- .../tests/unit/parser.decorator.test.ts | 28 ++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/parser/src/parser.ts b/packages/parser/src/parser.ts index 095d95eaa..c3ba727ae 100644 --- a/packages/parser/src/parser.ts +++ b/packages/parser/src/parser.ts @@ -39,7 +39,7 @@ const parser = ( const { schema, envelope } = options; - descriptor.value = function ( + descriptor.value = async function ( this: Handler, event: unknown, context: Context, diff --git a/packages/parser/tests/unit/parser.decorator.test.ts b/packages/parser/tests/unit/parser.decorator.test.ts index 62a310979..eba2a3b59 100644 --- a/packages/parser/tests/unit/parser.decorator.test.ts +++ b/packages/parser/tests/unit/parser.decorator.test.ts @@ -37,6 +37,14 @@ describe('Parser Decorator', () => { return event; } + @parser({ schema: TestSchema, envelope: eventBridgeEnvelope }) + public async handlerWithParserCallsAnotherMethod( + event: unknown, + _context: Context + ): Promise { + return this.anotherMethod(event as TestSchema); + } + @parser({ envelope: eventBridgeEnvelope, schema: TestSchema }) public async handlerWithSchemaAndEnvelope( event: unknown, @@ -44,6 +52,10 @@ describe('Parser Decorator', () => { ): Promise { return event; } + + private async anotherMethod(event: TestSchema): Promise { + return event; + } } const lambda = new TestClass(); @@ -88,5 +100,19 @@ describe('Parser Decorator', () => { expect(resp.detail).toEqual(customPayload); }); - it('should return original event if no schema is provided', async () => {}); + it('should parse and call private async method', async () => { + const customPayload = generateMock(TestSchema); + const testEvent = TestEvents.eventBridgeEvent as EventBridgeEvent< + string, + unknown + >; + testEvent.detail = customPayload; + + const resp = await lambda.handlerWithParserCallsAnotherMethod( + testEvent, + {} as Context + ); + + expect(resp).toEqual(customPayload); + }); });