Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(parser): implement parser decorator #1831

Merged
merged 6 commits into from
Dec 21, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions packages/parser/src/middleware/parser.ts
Original file line number Diff line number Diff line change
@@ -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<S extends ZodSchema> {
schema: S;
envelope?: Envelope;
}
import { type ParserOptions } from '../types/ParserOptions.js';

/**
* A middiy middleware to parse your event.
Expand Down
59 changes: 59 additions & 0 deletions packages/parser/src/parser.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> {
* // 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 = <S extends ZodSchema>(
options: ParserOptions<S>
): HandlerMethodDecorator => {
return (_target, _propertyKey, descriptor) => {
const original = descriptor.value!;

const { schema, envelope } = options;

descriptor.value = function (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this still keep the decorated method async? I haven't stepped through with the debugger, but I think with this implementation we are implicitly replacing an async method with a sync one and when calling it with the apply() we are returning an unresolved promise.

Not sure if this is an issue but I think this is why we made all other decorators async.

What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great observation! I checked in the debugger and the call original.apply() remains async, while descriptor.value is of course sync function and thus all the method of the class will be replaced with a sync function, changing return type value instead of Promise.

We could check original.constructor.name === AsyncFunction to decide what we should return. The more interesting case will bubble up once users want to bring their custom transforms or refines which include async code. This would force us to expose parseAsync and branch the parsing for envelopes and decorator/middy.

I'd suggest to change the modified descriptor.value to async, to keep it consistent with other decorator and we will circle back in a separate issue on this matter. We planned to address the sync/async decorators anyway.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea I agree that we should stick to async and keep things simple and consistent.

We discussed doing runtime checks for the constructor type to determine if it's sync or async when we implemented the Batch Processor and we eventually decided to drop the idea.

The main argument against this is that even though within the function body you might be able to do this check, the function signature will always have to be async, which then leads to the return type always being a Promise<T>.

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 };
9 changes: 9 additions & 0 deletions packages/parser/src/types/ParserOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ZodSchema } from 'zod';
import { Envelope } from './envelope.js';

type ParserOptions<S extends ZodSchema> = {
schema: S;
envelope?: Envelope;
};

export { type ParserOptions };
92 changes: 92 additions & 0 deletions packages/parser/tests/unit/parser.decorator.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof TestSchema>;

class TestClass implements LambdaInterface {
@parser({ schema: TestSchema })
public async handler(
event: TestSchema,
_context: Context
): Promise<unknown> {
return event;
}

@parser({ schema: customEventBridgeSchema })
public async handlerWithCustomSchema(
event: unknown,
_context: Context
): Promise<unknown> {
return event;
}

@parser({ envelope: eventBridgeEnvelope, schema: TestSchema })
public async handlerWithSchemaAndEnvelope(
event: unknown,
_context: Context
): Promise<unknown> {
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<typeof customEventBridgeSchema> =
(await lambda.handlerWithCustomSchema(
testEvent,
{} as Context
)) as z.infer<typeof customEventBridgeSchema>;

expect(customEventBridgeSchema.parse(resp)).toEqual(testEvent);
expect(resp.detail).toEqual(customPayload);
});

it('should return original event if no schema is provided', async () => {});
});