From 6626a6f856830686d97b58775d4e1078283f1597 Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Tue, 22 Apr 2025 07:29:31 -0700 Subject: [PATCH] feat(event-handler): AppSync Events resolver --- docs/features/event-handler/appsync-events.md | 410 ++++++++++++++++++ docs/features/index.md | 8 + docs/index.md | 23 +- .../appsync-events/accessEventAndContext.ts | 16 + .../appsync-events/aggregatedProcessing.ts | 38 ++ .../appsync-events/allOrNothingBatch.ts | 37 ++ .../appsync-events/debugLogging.ts | 16 + .../errorHandlingWithBatchOfItems.ts | 34 ++ .../errorHandlingWithIndividualItems.ts | 21 + .../appsync-events/gettingStartedOnPublish.ts | 14 + .../gettingStartedOnPublishDecorator.ts | 22 + .../gettingStartedOnSubscribe.ts | 18 + .../gettingStartedOnSubscribeDecorator.ts | 26 ++ .../samples/allOrNothingErrorResponse.json | 3 + .../samples/debugLogExcerpt.json | 17 + .../errorHandlingWithAggregateResponse.json | 17 + ...orHandlingWithIndividualItemsResponse.json | 14 + .../samples/gettingStartedRequest.json | 42 ++ .../samples/gettingStartedResponse.json | 16 + .../gettingStartedResponseWithError.json | 14 + .../samples/onPublishEvent.json | 47 ++ .../samples/onSubscribeEvent.json | 28 ++ .../templates/gettingStarted.yaml | 27 ++ .../appsync-events/testingEventsOnPublish.ts | 32 ++ .../testingEventsOnSubscribe.ts | 19 + .../appsync-events/unauthorizedException.ts | 24 + .../appsync-events/wildcardPatterns.ts | 15 + mkdocs.yml | 2 + packages/event-handler/README.md | 130 +++++- packages/event-handler/package.json | 27 +- .../appsync-events/AppSyncEventsResolver.ts | 204 +++++++++ .../appsync-events/RouteHandlerRegistry.ts | 157 +++++++ .../src/appsync-events/Router.ts | 286 ++++++++++++ .../src/appsync-events/errors.ts | 31 ++ .../event-handler/src/appsync-events/index.ts | 3 + .../event-handler/src/appsync-events/utils.ts | 67 +++ packages/event-handler/src/index.ts | 1 - .../event-handler/src/types/appsync-events.ts | 300 +++++++++++++ packages/event-handler/src/types/index.ts | 10 + .../event-handler/tests/events/onPublish.json | 47 ++ .../event-handler/tests/helpers/factories.ts | 77 ++++ .../tests/unit/AppSyncEventsResolver.test.ts | 307 +++++++++++++ .../tests/unit/RouteHandlerRegistry.test.ts | 173 ++++++++ .../event-handler/tests/unit/Router.test.ts | 100 +++++ .../event-handler/tests/unit/index.test.ts | 24 - packages/event-handler/typedoc.json | 10 + typedoc.json | 1 - 47 files changed, 2885 insertions(+), 70 deletions(-) create mode 100644 docs/features/event-handler/appsync-events.md create mode 100644 examples/snippets/event-handler/appsync-events/accessEventAndContext.ts create mode 100644 examples/snippets/event-handler/appsync-events/aggregatedProcessing.ts create mode 100644 examples/snippets/event-handler/appsync-events/allOrNothingBatch.ts create mode 100644 examples/snippets/event-handler/appsync-events/debugLogging.ts create mode 100644 examples/snippets/event-handler/appsync-events/errorHandlingWithBatchOfItems.ts create mode 100644 examples/snippets/event-handler/appsync-events/errorHandlingWithIndividualItems.ts create mode 100644 examples/snippets/event-handler/appsync-events/gettingStartedOnPublish.ts create mode 100644 examples/snippets/event-handler/appsync-events/gettingStartedOnPublishDecorator.ts create mode 100644 examples/snippets/event-handler/appsync-events/gettingStartedOnSubscribe.ts create mode 100644 examples/snippets/event-handler/appsync-events/gettingStartedOnSubscribeDecorator.ts create mode 100644 examples/snippets/event-handler/appsync-events/samples/allOrNothingErrorResponse.json create mode 100644 examples/snippets/event-handler/appsync-events/samples/debugLogExcerpt.json create mode 100644 examples/snippets/event-handler/appsync-events/samples/errorHandlingWithAggregateResponse.json create mode 100644 examples/snippets/event-handler/appsync-events/samples/errorHandlingWithIndividualItemsResponse.json create mode 100644 examples/snippets/event-handler/appsync-events/samples/gettingStartedRequest.json create mode 100644 examples/snippets/event-handler/appsync-events/samples/gettingStartedResponse.json create mode 100644 examples/snippets/event-handler/appsync-events/samples/gettingStartedResponseWithError.json create mode 100644 examples/snippets/event-handler/appsync-events/samples/onPublishEvent.json create mode 100644 examples/snippets/event-handler/appsync-events/samples/onSubscribeEvent.json create mode 100644 examples/snippets/event-handler/appsync-events/templates/gettingStarted.yaml create mode 100644 examples/snippets/event-handler/appsync-events/testingEventsOnPublish.ts create mode 100644 examples/snippets/event-handler/appsync-events/testingEventsOnSubscribe.ts create mode 100644 examples/snippets/event-handler/appsync-events/unauthorizedException.ts create mode 100644 examples/snippets/event-handler/appsync-events/wildcardPatterns.ts create mode 100644 packages/event-handler/src/appsync-events/AppSyncEventsResolver.ts create mode 100644 packages/event-handler/src/appsync-events/RouteHandlerRegistry.ts create mode 100644 packages/event-handler/src/appsync-events/Router.ts create mode 100644 packages/event-handler/src/appsync-events/errors.ts create mode 100644 packages/event-handler/src/appsync-events/index.ts create mode 100644 packages/event-handler/src/appsync-events/utils.ts delete mode 100644 packages/event-handler/src/index.ts create mode 100644 packages/event-handler/src/types/appsync-events.ts create mode 100644 packages/event-handler/src/types/index.ts create mode 100644 packages/event-handler/tests/events/onPublish.json create mode 100644 packages/event-handler/tests/helpers/factories.ts create mode 100644 packages/event-handler/tests/unit/AppSyncEventsResolver.test.ts create mode 100644 packages/event-handler/tests/unit/RouteHandlerRegistry.test.ts create mode 100644 packages/event-handler/tests/unit/Router.test.ts delete mode 100644 packages/event-handler/tests/unit/index.test.ts create mode 100644 packages/event-handler/typedoc.json diff --git a/docs/features/event-handler/appsync-events.md b/docs/features/event-handler/appsync-events.md new file mode 100644 index 0000000000..0f9de1a7ea --- /dev/null +++ b/docs/features/event-handler/appsync-events.md @@ -0,0 +1,410 @@ +--- +title: AppSync Events +description: Event Handler for AWS AppSync real-time events +status: new +--- + +Event Handler for AWS AppSync real-time events. + +```mermaid +stateDiagram-v2 + direction LR + EventSource: AppSync Events + EventHandlerResolvers: Publish & Subscribe events + LambdaInit: Lambda invocation + EventHandler: Event Handler + EventHandlerResolver: Route event based on namespace/channel + YourLogic: Run your registered handler function + EventHandlerResolverBuilder: Adapts response to AppSync contract + LambdaResponse: Lambda response + + state EventSource { + EventHandlerResolvers + } + + EventHandlerResolvers --> LambdaInit + + LambdaInit --> EventHandler + EventHandler --> EventHandlerResolver + + state EventHandler { + [*] --> EventHandlerResolver: app.resolve(event, context) + EventHandlerResolver --> YourLogic + YourLogic --> EventHandlerResolverBuilder + } + + EventHandler --> LambdaResponse +``` + +## Key Features + +* Easily handle publish and subscribe events with dedicated handler methods +* Automatic routing based on namespace and channel patterns +* Support for wildcard patterns to create catch-all handlers +* Process events in parallel corontrol aggregation for batch processing +* Graceful error handling for individual events + +## Terminology + +**[AWS AppSync Events](https://docs.aws.amazon.com/appsync/latest/eventapi/event-api-welcome.html){target="_blank"}**. A service that enables you to quickly build secure, scalable real-time WebSocket APIs without managing infrastructure or writing API code. + +It handles connection management, message broadcasting, authentication, and monitoring, reducing time to market and operational costs. + +## Getting started + +???+ tip "Tip: New to AppSync Real-time API?" + Visit [AWS AppSync Real-time documentation](https://docs.aws.amazon.com/appsync/latest/eventapi/event-api-getting-started.html){target="_blank"} to understand how to set up subscriptions and pub/sub messaging. + +### Required resources + +You must have an existing AppSync Events API with real-time capabilities enabled and IAM permissions to invoke your AWS Lambda function. That said, there are no additional permissions required to use Event Handler as routing requires no dependency. + +=== "template.yaml" + + ```yaml + --8<-- "examples/snippets/event-handler/appsync-events/templates/gettingStarted.yaml" + ``` + +### AppSync request and response format + +AppSync Events uses a specific event format for Lambda requests and responses. In most scenarios, Powertools for AWS simplifies this interaction by automatically formatting resolver returns to match the expected AppSync response structure. + +=== "Request format" + + ```json + --8<-- "examples/snippets/event-handler/appsync-events/samples/gettingStartedRequest.json" + ``` + +=== "Response format" + + ```json + --8<-- "examples/snippets/event-handler/appsync-events/samples/gettingStartedResponse.json" + ``` + +=== "Response format with error" + + ```json + --8<-- "examples/snippets/event-handler/appsync-events/samples/gettingStartedResponseWithError.json" + ``` + +#### Events response with error + +When processing events with Lambda, you can return errors to AppSync in three ways: + +* **Item specific error:** Return an `error` key within each individual item's response. AppSync Events expects this format for item-specific errors. +* **Fail entire request:** Return a JSON object with a top-level `error` key. This signals a general failure, and AppSync treats the entire request as unsuccessful. +* **Unauthorized exception**: Throw an **UnauthorizedException** exception to reject a subscribe or publish request with HTTP 403. + +### Route handlers + +!!! important "The event handler automatically parses the incoming event data and invokes the appropriate handler based on the namespace/channel pattern you register." + +You can define your handlers for different event types using the `onPublish()` and `onSubscribe()` methods and pass a function to handle the event. + +=== "Publish events" + + ```typescript hl_lines="4 6 14" + --8<-- "examples/snippets/event-handler/appsync-events/gettingStartedOnPublish.ts" + ``` + +=== "Subscribe events" + + ```typescript hl_lines="10 12 18" + --8<-- "examples/snippets/event-handler/appsync-events/gettingStartedOnSubscribe.ts" + ``` + +If you prefer to use the decorator syntax, you can instead use the same methods on a class method to register your handlers. + +=== "Publish events" + + ```typescript hl_lines="5 8 17" + --8<-- "examples/snippets/event-handler/appsync-events/gettingStartedOnPublishDecorator.ts" + ``` + +=== "Subscribe events" + + ```typescript hl_lines="11 14 21" + --8<-- "examples/snippets/event-handler/appsync-events/gettingStartedOnSubscribeDecorator.ts" + ``` + +## Advanced + +### Wildcard patterns and handler precedence + +You can use wildcard patterns to create catch-all handlers for multiple channels or namespaces. This is particularly useful for centralizing logic that applies to multiple channels. + +When an event matches with multiple handler, the most specific pattern takes precedence. + +!!! note "Supported wildcard patterns" + Only the following patterns are supported: + + * `/namespace/*` - Matches all channels in the specified namespace + * `/*` - Matches all channels in all namespaces + + Patterns like `/namespace/channel*` or `/namespace/*/subpath` are not supported. + + More specific handlers will always take precedence over less specific ones. For example, `/default/channel1` will take precedence over `/default/*`, which will take precedence over `/*`. + +=== "Wildcard patterns" + + ```typescript hl_lines="6 10" + --8<-- "examples/snippets/event-handler/appsync-events/wildcardPatterns.ts" + ``` + +If the event doesn't match any registered handler, the Event Handler will log a warning and skip processing the event. + +### Aggregated processing + +In some scenarios, you might want to process all messages published to a channel as a batch rather than individually. + +This is useful when you want to for example: + +* Optimize database operations by making a single batch query +* Ensure all events are processed together or not at all +* Apply custom error handling logic for the entire batch + +You can enable this with the `aggregate` parameter: + +!!! note "Aggregate Processing" + When enabling `aggregate`, your handler receives a list of all events, requiring you to manage the response format. Ensure your response includes results for each event in the expected [AppSync Request and Response Format](#appsync-request-and-response-format). + +=== "Aggregated processing" + + ```typescript hl_lines="17 32 34" + --8<-- "examples/snippets/event-handler/appsync-events/aggregatedProcessing.ts" + ``` + +### Handling errors + +You can filter or reject events by throwing exceptions in your resolvers or by formatting the payload according to the expected response structure. This instructs AppSync not to propagate that specific message, so subscribers will not receive it. + +#### Handling errors with individual items + +When processing items individually, you can throw an exception to fail a specific message. When this happens, the Event Handler will catch it and include the exception name and message in the response. + +=== "Error handling with individual items" + + ```typescript hl_lines="5 6 13" + --8<-- "examples/snippets/event-handler/appsync-events/errorHandlingWithIndividualItems.ts" + ``` + +=== "Response format with error" + + ```json hl_lines="3-6" + --8<-- "examples/snippets/event-handler/appsync-events/samples/errorHandlingWithIndividualItemsResponse.json" + ``` + +#### Handling errors with aggregate + +When processing batch of items with `aggregate` enabled, you must format the payload according the expected response. + +=== "Error handling with batch of items" + + ```typescript hl_lines="21-24" + --8<-- "examples/snippets/event-handler/appsync-events/errorHandlingWithBatchOfItems.ts" + ``` + +=== "Response with errors in individual items" + + ```json + --8<-- "examples/snippets/event-handler/appsync-events/samples/errorHandlingWithAggregateResponse.json" + ``` + +If instead you want to fail the entire batch, you can throw an exception. This will cause the Event Handler to return an error response to AppSync and fail the entire batch. + +=== "All or nothing error handling with batch of items" + + ```typescript hl_lines="21-24" + --8<-- "examples/snippets/event-handler/appsync-events/allOrNothingBatch.ts" + ``` + +=== "Response with entire batch error" + + ```json + --8<-- "examples/snippets/event-handler/appsync-events/samples/allOrNothingErrorResponse.json" + ``` + +#### Authorization control + +!!! warning "Throwing `UnauthorizedException` will cause the Lambda invocation to fail." + +You can also do content-based authorization for channel by throwing an `UnauthorizedException` error. This can cause two situations: + +* **When working with publish events**, Powertools for AWS stops processing messages and prevents subscribers from receiving messages. +* **When working with subscribe events** it'll prevent the subscription from being created. + +=== "UnauthorizedException" + + ```typescript hl_lines="3 14 20" + --8<-- "examples/snippets/event-handler/appsync-events/unauthorizedException.ts" + ``` + +### Accessing Lambda context and event + +You can access to the original Lambda event or context for additional information. These are passed to the handler function as optional arguments. + +=== "Access event and context" + + ```typescript hl_lines="6" + --8<-- "examples/snippets/event-handler/appsync-events/accessEventAndContext.ts" + ``` + + 1. The `event` parameter contains the original AppSync event and has type `AppSyncEventsPublishEvent` or `AppSyncEventsSubscribeEvent` from the `@aws-lambda-powertools/event-handler/types`. + +### Logging + +By default, the `AppSyncEventsResolver` uses the global `console` logger and emits only warnings and errors. + +You can change this behavior by passing a custom logger instance to the `AppSyncEventsResolver` and setting the log level for it, or by enabling [Lambda Advanced Logging Controls](https://docs.aws.amazon.com/lambda/latest/dg/monitoring-cloudwatchlogs-advanced.html) and setting the log level to `DEBUG`. + +When debug logging is enabled, the resolver will emit logs that show the underlying handler resolution process. This is useful for understanding how your handlers are being resolved and invoked and can help you troubleshoot issues with your event processing. + +For example, when using the [Powertools for AWS Lambda logger](../logger.md), you can set the `LOG_LEVEL` to `DEBUG` in your environment variables or at the logger level and pass the logger instance to the `AppSyncEventsResolver` constructor to enable debug logging. + +=== "Debug logging" + + ```typescript hl_lines="9" + --8<-- "examples/snippets/event-handler/appsync-events/debugLogging.ts" + ``` + +=== "Logs output" + + ```json + --8<-- "examples/snippets/event-handler/appsync-events/samples/debugLogExcerpt.json" + ``` + +## Flow diagrams + +### Working with single items + +
+```mermaid +sequenceDiagram + participant Client + participant AppSync + participant Lambda + participant EventHandler + note over Client,EventHandler: Individual Event Processing (aggregate=False) + Client->>+AppSync: Send multiple events to channel + AppSync->>+Lambda: Invoke Lambda with batch of events + Lambda->>+EventHandler: Process events with aggregate=False + loop For each event in batch + EventHandler->>EventHandler: Process individual event + end + EventHandler-->>-Lambda: Return array of processed events + Lambda-->>-AppSync: Return event-by-event responses + AppSync-->>-Client: Report individual event statuses +``` +
+ +### Working with aggregated items + +
+```mermaid +sequenceDiagram + participant Client + participant AppSync + participant Lambda + participant EventHandler + note over Client,EventHandler: Aggregate Processing Workflow + Client->>+AppSync: Send multiple events to channel + AppSync->>+Lambda: Invoke Lambda with batch of events + Lambda->>+EventHandler: Process events with aggregate=True + EventHandler->>EventHandler: Batch of events + EventHandler->>EventHandler: Process entire batch at once + EventHandler->>EventHandler: Format response for each event + EventHandler-->>-Lambda: Return aggregated results + Lambda-->>-AppSync: Return success responses + AppSync-->>-Client: Confirm all events processed +``` +
+ +### Unauthorized publish + +
+```mermaid +sequenceDiagram + participant Client + participant AppSync + participant Lambda + participant EventHandler + note over Client,EventHandler: Publish Event Authorization Flow + Client->>AppSync: Publish message to channel + AppSync->>Lambda: Invoke Lambda with publish event + Lambda->>EventHandler: Process publish event + alt Authorization Failed + EventHandler->>EventHandler: Authorization check fails + EventHandler->>Lambda: Raise UnauthorizedException + Lambda->>AppSync: Return error response + AppSync--xClient: Message not delivered + AppSync--xAppSync: No distribution to subscribers + else Authorization Passed + EventHandler->>Lambda: Return successful response + Lambda->>AppSync: Return processed event + AppSync->>Client: Acknowledge message + AppSync->>AppSync: Distribute to subscribers + end +``` +
+ +### Unauthorized subscribe + +
+```mermaid +sequenceDiagram + participant Client + participant AppSync + participant Lambda + participant EventHandler + note over Client,EventHandler: Subscribe Event Authorization Flow + Client->>AppSync: Request subscription to channel + AppSync->>Lambda: Invoke Lambda with subscribe event + Lambda->>EventHandler: Process subscribe event + alt Authorization Failed + EventHandler->>EventHandler: Authorization check fails + EventHandler->>Lambda: Raise UnauthorizedException + Lambda->>AppSync: Return error response + AppSync--xClient: Subscription denied (HTTP 403) + else Authorization Passed + EventHandler->>Lambda: Return successful response + Lambda->>AppSync: Return authorization success + AppSync->>Client: Subscription established + end +``` +
+ +## Testing your code + +You can test your event handlers by passing a mock payload with the expected structure. + +For example, when working with `PUBLISH` events, you can use the `OnPublishOutput` to easily cast the output of your handler to the expected type and assert the expected values. + +=== "Testing publish events" + + ```typescript hl_lines="5 55" + --8<-- "examples/snippets/event-handler/appsync-events/testingEventsOnPublish.ts" + ``` + + 1. See [here](#route-handlers) to see the implementation of this handler. + +=== "Sample publish event" + + ```json + --8<-- "examples/snippets/event-handler/appsync-events/samples/onPublishEvent.json" + ``` + +You can also assert that a handler throws an exception when processing a specific event. + +=== "Testing subscribe events" + + ```typescript hl_lines="5 15-17" + --8<-- "examples/snippets/event-handler/appsync-events/testingEventsOnSubscribe.ts" + ``` + + 1. See [here](#authorization-control) to see the implementation of this handler. + +=== "Sample subscribe event" + + ```json + --8<-- "examples/snippets/event-handler/appsync-events/samples/onSubscribeEvent.json" + ``` diff --git a/docs/features/index.md b/docs/features/index.md index 808248f9a6..f06ae4701d 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -31,6 +31,14 @@ description: Features of Powertools for AWS Lambda [:octicons-arrow-right-24: Read more](./metrics.md) +- __Event Handler - AppSync Events__ + + --- + + Event Handler for AWS AppSync real-time events + + [:octicons-arrow-right-24: Read more](./event-handler/appsync-events.md) + - __Parameters__ --- diff --git a/docs/index.md b/docs/index.md index e94605f266..d0b104d356 100644 --- a/docs/index.md +++ b/docs/index.md @@ -42,17 +42,18 @@ You can use Powertools for AWS Lambda in both TypeScript and JavaScript code bas Powertools for AWS Lambda (TypeScript) is built as a modular toolkit, so you can pick and choose the utilities you want to use. The following table lists the available utilities, and links to their documentation. -| Utility | Description | -| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- ------------------------------- | -| [Tracer](./features/tracer.md) | Decorators and utilities to trace Lambda function handlers, and both synchronous and asynchronous functions | -| [Logger](./features/logger.md) | Structured logging made easier, and a middleware to enrich structured logging with key Lambda context details | -| [Metrics](./features/metrics.md) | Custom Metrics created asynchronously via CloudWatch Embedded Metric Format (EMF) | -| [Parameters](./features/parameters.md) | High-level functions to retrieve one or more parameters from AWS SSM Parameter Store, AWS Secrets Manager, AWS AppConfig, and Amazon DynamoDB | -| [Idempotency](./features/idempotency.md) | Class method decorator, Middy middleware, and function wrapper to make your Lambda functions idempotent and prevent duplicate execution based on payload content. | -| [Batch Processing](./features/batch.md) | Utility to handle partial failures when processing batches from Amazon SQS, Amazon Kinesis Data Streams, and Amazon DynamoDB Streams. | -| [JMESPath Functions](./features/jmespath.md) | Built-in JMESPath functions to easily deserialize common encoded JSON payloads in Lambda functions. | -| [Parser](./features/parser.md) | Utility to parse and validate AWS Lambda event payloads using Zod, a TypeScript-first schema declaration and validation library. | -| [Validation](./features/validation.md) | JSON Schema validation for events and responses, including JMESPath support to unwrap events before validation. | +| Utility | Description | +| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Tracer](./features/tracer.md) | Decorators and utilities to trace Lambda function handlers, and both synchronous and asynchronous functions | +| [Logger](./features/logger.md) | Structured logging made easier, and a middleware to enrich structured logging with key Lambda context details | +| [Metrics](./features/metrics.md) | Custom Metrics created asynchronously via CloudWatch Embedded Metric Format (EMF) | +| [Event Handler - AppSync Events](./features/event-handler/appsync-events.md) | Event Handler for AWS AppSync real-time events | +| [Parameters](./features/parameters.md) | High-level functions to retrieve one or more parameters from AWS SSM Parameter Store, AWS Secrets Manager, AWS AppConfig, and Amazon DynamoDB | +| [Idempotency](./features/idempotency.md) | Class method decorator, Middy middleware, and function wrapper to make your Lambda functions idempotent and prevent duplicate execution based on payload content. | +| [Batch Processing](./features/batch.md) | Utility to handle partial failures when processing batches from Amazon SQS, Amazon Kinesis Data Streams, and Amazon DynamoDB Streams. | +| [JMESPath Functions](./features/jmespath.md) | Built-in JMESPath functions to easily deserialize common encoded JSON payloads in Lambda functions. | +| [Parser](./features/parser.md) | Utility to parse and validate AWS Lambda event payloads using Zod, a TypeScript-first schema declaration and validation library. | +| [Validation](./features/validation.md) | JSON Schema validation for events and responses, including JMESPath support to unwrap events before validation. | ## Examples diff --git a/examples/snippets/event-handler/appsync-events/accessEventAndContext.ts b/examples/snippets/event-handler/appsync-events/accessEventAndContext.ts new file mode 100644 index 0000000000..c2446b5374 --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/accessEventAndContext.ts @@ -0,0 +1,16 @@ +import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; +import type { Context } from 'aws-lambda'; + +const app = new AppSyncEventsResolver(); + +app.onPublish('/*', (payload, event, context) => { + const { headers } = event.request; // (1)! + const { awsRequestId } = context; + + // your business logic here + + return payload; +}); + +export const handler = async (event: unknown, context: Context) => + await app.resolve(event, context); diff --git a/examples/snippets/event-handler/appsync-events/aggregatedProcessing.ts b/examples/snippets/event-handler/appsync-events/aggregatedProcessing.ts new file mode 100644 index 0000000000..ad9ce6191d --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/aggregatedProcessing.ts @@ -0,0 +1,38 @@ +import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; +import { + BatchWriteItemCommand, + DynamoDBClient, + type WriteRequest, +} from '@aws-sdk/client-dynamodb'; +import { marshall } from '@aws-sdk/util-dynamodb'; +import type { Context } from 'aws-lambda'; + +const ddbClient = new DynamoDBClient(); +const app = new AppSyncEventsResolver(); + +app.onPublish( + '/default/foo/*', + async (payloads) => { + const writeOperations: WriteRequest[] = []; + for (const payload of payloads) { + writeOperations.push({ + PutRequest: { + Item: marshall(payload), + }, + }); + } + await ddbClient.send( + new BatchWriteItemCommand({ + RequestItems: { + 'your-table-name': writeOperations, + }, + }) + ); + + return payloads; + }, + { aggregate: true } +); + +export const handler = async (event: unknown, context: Context) => + app.resolve(event, context); diff --git a/examples/snippets/event-handler/appsync-events/allOrNothingBatch.ts b/examples/snippets/event-handler/appsync-events/allOrNothingBatch.ts new file mode 100644 index 0000000000..55f5da1636 --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/allOrNothingBatch.ts @@ -0,0 +1,37 @@ +import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; +import type { OnPublishAggregateOutput } from '@aws-lambda-powertools/event-handler/types'; +import { Logger } from '@aws-lambda-powertools/logger'; +import type { Context } from 'aws-lambda'; + +const logger = new Logger({ + serviceName: 'serverlessAirline', + logLevel: 'INFO', +}); +const app = new AppSyncEventsResolver(); + +app.onPublish( + '/default/foo/*', + async (payloads) => { + const returnValues: OnPublishAggregateOutput<{ + processed: boolean; + original_payload: unknown; + }> = []; + try { + for (const payload of payloads) { + returnValues.push({ + id: payload.id, + payload: { processed: true, original_payload: payload }, + }); + } + } catch (error) { + logger.error('Error processing payloads', { error }); + throw error; + } + + return returnValues; + }, + { aggregate: true } +); + +export const handler = async (event: unknown, context: Context) => + app.resolve(event, context); diff --git a/examples/snippets/event-handler/appsync-events/debugLogging.ts b/examples/snippets/event-handler/appsync-events/debugLogging.ts new file mode 100644 index 0000000000..ecc16c1269 --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/debugLogging.ts @@ -0,0 +1,16 @@ +import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; +import { Logger } from '@aws-lambda-powertools/logger'; +import type { Context } from 'aws-lambda'; + +const logger = new Logger({ + serviceName: 'serverlessAirline', + logLevel: 'DEBUG', +}); +const app = new AppSyncEventsResolver({ logger }); + +app.onPublish('/default/foo', (payload) => { + return payload; +}); + +export const handler = async (event: unknown, context: Context) => + await app.resolve(event, context); diff --git a/examples/snippets/event-handler/appsync-events/errorHandlingWithBatchOfItems.ts b/examples/snippets/event-handler/appsync-events/errorHandlingWithBatchOfItems.ts new file mode 100644 index 0000000000..9bf3b7024e --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/errorHandlingWithBatchOfItems.ts @@ -0,0 +1,34 @@ +import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; +import type { OnPublishAggregateOutput } from '@aws-lambda-powertools/event-handler/types'; +import type { Context } from 'aws-lambda'; + +const app = new AppSyncEventsResolver(); + +app.onPublish( + '/default/foo/*', + async (payloads) => { + const returnValues: OnPublishAggregateOutput<{ + processed: boolean; + original_payload: unknown; + }> = []; + for (const payload of payloads) { + try { + returnValues.push({ + id: payload.id, + payload: { processed: true, original_payload: payload }, + }); + } catch (error) { + returnValues.push({ + id: payload.id, + error: `${error.name} - ${error.message}`, + }); + } + } + + return returnValues; + }, + { aggregate: true } +); + +export const handler = async (event: unknown, context: Context) => + app.resolve(event, context); diff --git a/examples/snippets/event-handler/appsync-events/errorHandlingWithIndividualItems.ts b/examples/snippets/event-handler/appsync-events/errorHandlingWithIndividualItems.ts new file mode 100644 index 0000000000..b79e5b1c97 --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/errorHandlingWithIndividualItems.ts @@ -0,0 +1,21 @@ +import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; +import { Logger } from '@aws-lambda-powertools/logger'; +import type { Context } from 'aws-lambda'; + +const logger = new Logger({ + serviceName: 'appsync-events', + logLevel: 'DEBUG', +}); +const app = new AppSyncEventsResolver(); + +app.onPublish('/default/foo', (payload) => { + try { + return payload; + } catch (error) { + logger.error('Error processing event', { error }); + throw error; + } +}); + +export const handler = async (event: unknown, context: Context) => + app.resolve(event, context); diff --git a/examples/snippets/event-handler/appsync-events/gettingStartedOnPublish.ts b/examples/snippets/event-handler/appsync-events/gettingStartedOnPublish.ts new file mode 100644 index 0000000000..1c2fec5ad4 --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/gettingStartedOnPublish.ts @@ -0,0 +1,14 @@ +import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; +import type { Context } from 'aws-lambda'; + +const app = new AppSyncEventsResolver(); + +app.onPublish('/default/foo', (payload) => { + return { + processed: true, + original_payload: payload, + }; +}); + +export const handler = async (event: unknown, context: Context) => + app.resolve(event, context); diff --git a/examples/snippets/event-handler/appsync-events/gettingStartedOnPublishDecorator.ts b/examples/snippets/event-handler/appsync-events/gettingStartedOnPublishDecorator.ts new file mode 100644 index 0000000000..b577acc29d --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/gettingStartedOnPublishDecorator.ts @@ -0,0 +1,22 @@ +import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; +import type { AppSyncEventsPublishEvent } from '@aws-lambda-powertools/event-handler/types'; +import type { Context } from 'aws-lambda'; + +const app = new AppSyncEventsResolver(); + +class Lambda { + @app.onPublish('/default/foo') + async fooHandler(payload: AppSyncEventsPublishEvent) { + return { + processed: true, + original_payload: payload, + }; + } + + async handler(event: unknown, context: Context) { + return app.resolve(event, context); + } +} + +const lambda = new Lambda(); +export const handler = lambda.handler.bind(lambda); diff --git a/examples/snippets/event-handler/appsync-events/gettingStartedOnSubscribe.ts b/examples/snippets/event-handler/appsync-events/gettingStartedOnSubscribe.ts new file mode 100644 index 0000000000..abd8ba8ec4 --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/gettingStartedOnSubscribe.ts @@ -0,0 +1,18 @@ +import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; +import { MetricUnit, Metrics } from '@aws-lambda-powertools/metrics'; +import type { Context } from 'aws-lambda'; + +const metrics = new Metrics({ + namespace: 'serverlessAirline', + serviceName: 'chat', + singleMetric: true, +}); +const app = new AppSyncEventsResolver(); + +app.onSubscribe('/default/foo', (event) => { + metrics.addDimension('channel', event.info.channel.path); + metrics.addMetric('connections', MetricUnit.Count, 1); +}); + +export const handler = async (event: unknown, context: Context) => + app.resolve(event, context); diff --git a/examples/snippets/event-handler/appsync-events/gettingStartedOnSubscribeDecorator.ts b/examples/snippets/event-handler/appsync-events/gettingStartedOnSubscribeDecorator.ts new file mode 100644 index 0000000000..43ba8e551d --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/gettingStartedOnSubscribeDecorator.ts @@ -0,0 +1,26 @@ +import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; +import type { AppSyncEventsSubscribeEvent } from '@aws-lambda-powertools/event-handler/types'; +import { MetricUnit, Metrics } from '@aws-lambda-powertools/metrics'; +import type { Context } from 'aws-lambda'; + +const metrics = new Metrics({ + namespace: 'serverlessAirline', + serviceName: 'chat', + singleMetric: true, +}); +const app = new AppSyncEventsResolver(); + +class Lambda { + @app.onSubscribe('/default/foo') + async fooHandler(event: AppSyncEventsSubscribeEvent) { + metrics.addDimension('channel', event.info.channel.path); + metrics.addMetric('connections', MetricUnit.Count, 1); + } + + async handler(event: unknown, context: Context) { + return app.resolve(event, context); + } +} + +const lambda = new Lambda(); +export const handler = lambda.handler.bind(lambda); diff --git a/examples/snippets/event-handler/appsync-events/samples/allOrNothingErrorResponse.json b/examples/snippets/event-handler/appsync-events/samples/allOrNothingErrorResponse.json new file mode 100644 index 0000000000..18742c255b --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/samples/allOrNothingErrorResponse.json @@ -0,0 +1,3 @@ +{ + "error": "Error - An unexpected error occurred" +} \ No newline at end of file diff --git a/examples/snippets/event-handler/appsync-events/samples/debugLogExcerpt.json b/examples/snippets/event-handler/appsync-events/samples/debugLogExcerpt.json new file mode 100644 index 0000000000..b667127c10 --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/samples/debugLogExcerpt.json @@ -0,0 +1,17 @@ +[ + { + "level": "DEBUG", + "message": "Registering onPublish route handler for path '/default/foo' with aggregate 'false'", + "timestamp": "2025-04-22T13:24:34.762Z", + "service": "serverlessAirline", + "sampling_rate": 0 + }, + { + "level": "DEBUG", + "message": "Resolving handler for path '/default/foo'", + "timestamp": "2025-04-22T13:24:34.775Z", + "service": "serverlessAirline", + "sampling_rate": 0, + "xray_trace_id": "1-68079892-6a1723770bc0b1f348d9a7ad" + } +] \ No newline at end of file diff --git a/examples/snippets/event-handler/appsync-events/samples/errorHandlingWithAggregateResponse.json b/examples/snippets/event-handler/appsync-events/samples/errorHandlingWithAggregateResponse.json new file mode 100644 index 0000000000..51396a3d0a --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/samples/errorHandlingWithAggregateResponse.json @@ -0,0 +1,17 @@ +{ + "events": [ + { + "error": "SyntaxError - Invalid item", + "id": "1" + }, + { + "payload": { + "processed": true, + "original_payload": { + "event_2": "data_2" + } + }, + "id": "2" + } + ] +} \ No newline at end of file diff --git a/examples/snippets/event-handler/appsync-events/samples/errorHandlingWithIndividualItemsResponse.json b/examples/snippets/event-handler/appsync-events/samples/errorHandlingWithIndividualItemsResponse.json new file mode 100644 index 0000000000..e35b577092 --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/samples/errorHandlingWithIndividualItemsResponse.json @@ -0,0 +1,14 @@ +{ + "events": [ + { + "error": "Error message", + "id": "1" + }, + { + "payload": { + "data": "data_2" + }, + "id": "2" + } + ] +} \ No newline at end of file diff --git a/examples/snippets/event-handler/appsync-events/samples/gettingStartedRequest.json b/examples/snippets/event-handler/appsync-events/samples/gettingStartedRequest.json new file mode 100644 index 0000000000..9e009f51ba --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/samples/gettingStartedRequest.json @@ -0,0 +1,42 @@ +{ + "identity": "None", + "result": "None", + "request": { + "headers": { + "x-forwarded-for": "1.1.1.1, 2.2.2.2", + "cloudfront-viewer-country": "US" + }, + "domainName": "None" + }, + "info": { + "channel": { + "path": "/default/channel", + "segments": [ + "default", + "channel" + ] + }, + "channelNamespace": { + "name": "default" + }, + "operation": "PUBLISH" + }, + "error": "None", + "prev": "None", + "stash": {}, + "outErrors": [], + "events": [ + { + "payload": { + "data": "data_1" + }, + "id": "1" + }, + { + "payload": { + "data": "data_2" + }, + "id": "2" + } + ] +} \ No newline at end of file diff --git a/examples/snippets/event-handler/appsync-events/samples/gettingStartedResponse.json b/examples/snippets/event-handler/appsync-events/samples/gettingStartedResponse.json new file mode 100644 index 0000000000..e3b94c060c --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/samples/gettingStartedResponse.json @@ -0,0 +1,16 @@ +{ + "events": [ + { + "payload": { + "data": "data_1" + }, + "id": "1" + }, + { + "payload": { + "data": "data_2" + }, + "id": "2" + } + ] +} \ No newline at end of file diff --git a/examples/snippets/event-handler/appsync-events/samples/gettingStartedResponseWithError.json b/examples/snippets/event-handler/appsync-events/samples/gettingStartedResponseWithError.json new file mode 100644 index 0000000000..e35b577092 --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/samples/gettingStartedResponseWithError.json @@ -0,0 +1,14 @@ +{ + "events": [ + { + "error": "Error message", + "id": "1" + }, + { + "payload": { + "data": "data_2" + }, + "id": "2" + } + ] +} \ No newline at end of file diff --git a/examples/snippets/event-handler/appsync-events/samples/onPublishEvent.json b/examples/snippets/event-handler/appsync-events/samples/onPublishEvent.json new file mode 100644 index 0000000000..1d92f612dc --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/samples/onPublishEvent.json @@ -0,0 +1,47 @@ +{ + "identity": null, + "result": null, + "request": { + "headers": { + "key": "value" + }, + "domainName": null + }, + "info": { + "channel": { + "path": "/default/foo", + "segments": [ + "default", + "foo" + ] + }, + "channelNamespace": { + "name": "default" + }, + "operation": "PUBLISH" + }, + "error": null, + "prev": null, + "stash": {}, + "outErrors": [], + "events": [ + { + "payload": { + "event_1": "data_1" + }, + "id": "5f7dfbd1-b8ff-4c20-924e-23b42db467a0" + }, + { + "payload": { + "event_2": "data_2" + }, + "id": "ababdf65-a3e6-4c1d-acd3-87466eab433c" + }, + { + "payload": { + "event_3": "data_3" + }, + "id": "8bb2983a-0967-45a0-8243-0aeb8c83d80e" + } + ] +} \ No newline at end of file diff --git a/examples/snippets/event-handler/appsync-events/samples/onSubscribeEvent.json b/examples/snippets/event-handler/appsync-events/samples/onSubscribeEvent.json new file mode 100644 index 0000000000..509f4b838a --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/samples/onSubscribeEvent.json @@ -0,0 +1,28 @@ +{ + "identity": null, + "result": null, + "request": { + "headers": { + "key": "value" + }, + "domainName": null + }, + "info": { + "channel": { + "path": "/default/bar", + "segments": [ + "default", + "bar" + ] + }, + "channelNamespace": { + "name": "default" + }, + "operation": "SUBSCRIBE" + }, + "error": null, + "prev": null, + "stash": {}, + "outErrors": [], + "events": null +} \ No newline at end of file diff --git a/examples/snippets/event-handler/appsync-events/templates/gettingStarted.yaml b/examples/snippets/event-handler/appsync-events/templates/gettingStarted.yaml new file mode 100644 index 0000000000..e5f5c3c127 --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/templates/gettingStarted.yaml @@ -0,0 +1,27 @@ +Resources: + WebsocketAPI: + Type: AWS::AppSync::Api + Properties: + EventConfig: + AuthProviders: + - AuthType: API_KEY + ConnectionAuthModes: + - AuthType: API_KEY + DefaultPublishAuthModes: + - AuthType: API_KEY + DefaultSubscribeAuthModes: + - AuthType: API_KEY + Name: RealTimeEventAPI + + WebasocketApiKey: + Type: AWS::AppSync::ApiKey + Properties: + ApiId: !GetAtt WebsocketAPI.ApiId + Description: "API KEY" + Expires: 90 + + WebsocketAPINamespace: + Type: AWS::AppSync::ChannelNamespace + Properties: + ApiId: !GetAtt WebsocketAPI.ApiId + Name: powertools \ No newline at end of file diff --git a/examples/snippets/event-handler/appsync-events/testingEventsOnPublish.ts b/examples/snippets/event-handler/appsync-events/testingEventsOnPublish.ts new file mode 100644 index 0000000000..6a1715cd30 --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/testingEventsOnPublish.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs'; +import type { OnPublishOutput } from '@aws-lambda-powertools/event-handler/types'; +import type { Context } from 'aws-lambda'; +import { describe, expect, it } from 'vitest'; +import { handler } from './gettingStartedOnPublish.js'; // (1)! + +describe('On publish', () => { + it('handles publish on /default/foo', async () => { + // Prepare + const event = structuredClone( + JSON.parse(readFileSync('./samples/onPublishEvent.json', 'utf-8')) + ); + + // Act + const result = (await handler(event, {} as Context)) as OnPublishOutput; + + // Assess + expect(result.events).toHaveLength(3); + expect(result.events[0].payload).toEqual({ + processed: true, + original_payload: event.events[0].payload, + }); + expect(result.events[1].payload).toEqual({ + processed: true, + original_payload: event.events[1].payload, + }); + expect(result.events[2].payload).toEqual({ + processed: true, + original_payload: event.events[2].payload, + }); + }); +}); diff --git a/examples/snippets/event-handler/appsync-events/testingEventsOnSubscribe.ts b/examples/snippets/event-handler/appsync-events/testingEventsOnSubscribe.ts new file mode 100644 index 0000000000..f3343bd7ce --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/testingEventsOnSubscribe.ts @@ -0,0 +1,19 @@ +import { readFileSync } from 'node:fs'; +import { UnauthorizedException } from '@aws-lambda-powertools/event-handler/appsync-events'; +import type { Context } from 'aws-lambda'; +import { describe, expect, it } from 'vitest'; +import { handler } from './unauthorizedException.js'; // (1)! + +describe('On publish', () => { + it('rejects subscriptions on /default/bar', async () => { + // Prepare + const event = structuredClone( + JSON.parse(readFileSync('./samples/onSubscribeEvent.json', 'utf-8')) + ); + + // Act & Assess + await expect(() => handler(event, {} as Context)).rejects.toThrow( + UnauthorizedException + ); + }); +}); diff --git a/examples/snippets/event-handler/appsync-events/unauthorizedException.ts b/examples/snippets/event-handler/appsync-events/unauthorizedException.ts new file mode 100644 index 0000000000..7c1201299a --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/unauthorizedException.ts @@ -0,0 +1,24 @@ +import { + AppSyncEventsResolver, + UnauthorizedException, +} from '@aws-lambda-powertools/event-handler/appsync-events'; +import type { Context } from 'aws-lambda'; + +const app = new AppSyncEventsResolver(); + +app.onPublish('/default/foo', (payload) => { + return payload; +}); + +app.onPublish('/*', () => { + throw new UnauthorizedException('You can only publish to /default/foo'); +}); + +app.onSubscribe('/default/foo', () => true); + +app.onSubscribe('/*', () => { + throw new UnauthorizedException('You can only subscribe to /default/foo'); +}); + +export const handler = async (event: unknown, context: Context) => + await app.resolve(event, context); diff --git a/examples/snippets/event-handler/appsync-events/wildcardPatterns.ts b/examples/snippets/event-handler/appsync-events/wildcardPatterns.ts new file mode 100644 index 0000000000..38407f1412 --- /dev/null +++ b/examples/snippets/event-handler/appsync-events/wildcardPatterns.ts @@ -0,0 +1,15 @@ +import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; +import type { Context } from 'aws-lambda'; + +const app = new AppSyncEventsResolver(); + +app.onPublish('/default/*', (_payload) => { + // your logic here +}); + +app.onSubscribe('/*', (_payload) => { + // your logic here +}); + +export const handler = async (event: unknown, context: Context) => + app.resolve(event, context); diff --git a/mkdocs.yml b/mkdocs.yml index 28ce8b07a2..a2365b395e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,6 +43,8 @@ nav: - features/tracer.md - features/logger.md - features/metrics.md + - Event Handler: + - features/event-handler/appsync-events.md - features/parameters.md - features/idempotency.md - features/batch.md diff --git a/packages/event-handler/README.md b/packages/event-handler/README.md index 4689f15070..1aae035812 100644 --- a/packages/event-handler/README.md +++ b/packages/event-handler/README.md @@ -1,8 +1,5 @@ # Powertools for AWS Lambda (TypeScript) - Event Handler Utility -> [!Warning] -> This feature is currently under development. As such it's considered not stable and we might make significant breaking changes before going [before its release](https://github.com/aws-powertools/powertools-lambda-typescript/milestone/17). You are welcome to [provide feedback](https://github.com/aws-powertools/powertools-lambda-typescript/issues/413) and [contribute to the project](https://docs.powertools.aws.dev/lambda/typescript/latest/contributing/getting_started/). - Powertools for AWS Lambda (TypeScript) is a developer toolkit to implement Serverless [best practices and increase developer velocity](https://docs.powertools.aws.dev/lambda/typescript/latest/#features). You can use the library in both TypeScript and JavaScript code bases. @@ -19,8 +16,93 @@ To get started, install the library by running: npm i @aws-lambda-powertools/event-handler ``` -> [!Note] -> This readme is a work in progress. +## AppSync Events + +Event Handler for AWS AppSync real-time events. + +* Easily handle publish and subscribe events with dedicated handler methods +* Automatic routing based on namespace and channel patterns +* Support for wildcard patterns to create catch-all handlers +* Process events in parallel corontrol aggregation for batch processing +* Graceful error handling for individual events + +### Handle publish events + +When using the publish event handler, you can register a handler for a specific channel or a wildcard pattern. The handler will be called once for each message received on that channel. + +```typescript +import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; + +const app = new AppSyncEventsResolver(); + +app.onPublish('/default/foo', async (payload) => { + // your logic here + return payload; +}); + +export const handler = async (event, context) => + app.resolve(event, context); +``` + +In some cases, you might want to process all the messages at once, for example to optimize downstream operations. In this case, you can set the `aggregate` option to `true` when registering the handler. This will cause the handler to be called once for all messages received on that channel. + +```typescript +import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; + +const app = new AppSyncEventsResolver(); + +app.onPublish('/default/foo', async (payloads) => { + const newMessages = []; + for (const message of payloads) { + // your logic here + } + + return newMessages; +}, { + aggregate: true +}); + +export const handler = async (event, context) => + app.resolve(event, context); +``` + +### Handle subscribe events + +You can also register a handler for subscribe events. This handler will be called once for each subscription request received on the specified channel. You can use this handler to perform any necessary setup or validation before allowing the subscription to proceed. + +```typescript +import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; + +const app = new AppSyncEventsResolver(); + +app.onSubscribe('/default/foo', async (event) => { + // your logic here +}); + +export const handler = async (event, context) => + app.resolve(event, context); +``` + +If you want to reject a subscription request, you can throw an `UnauthorizedException` error. This will cause the subscription to be rejected and the client will receive an error message. + +```typescript +import { + AppSyncEventsResolver, + UnauthorizedException, +} from '@aws-lambda-powertools/event-handler/appsync-events'; + +const app = new AppSyncEventsResolver(); + +app.onSubscribe('/default/foo', async (event) => { + // your logic here + throw new UnauthorizedException('Unauthorized'); +}); + +export const handler = async (event, context) => + app.resolve(event, context); +``` + +See the [documentation](https://docs.powertools.aws.dev/lambda/typescript/latest/features/event-handler/appsync-events) for more details on how to use the AppSync event handler. ## Contribute @@ -33,8 +115,8 @@ Help us prioritize upcoming functionalities or utilities by [upvoting existing R ## Connect -- **Powertools for AWS Lambda on Discord**: `#typescript` - **[Invite link](https://discord.gg/B8zZKbbyET)** -- **Email**: +* **Powertools for AWS Lambda on Discord**: `#typescript` - **[Invite link](https://discord.gg/B8zZKbbyET)** +* **Email**: ## How to support Powertools for AWS Lambda (TypeScript)? @@ -44,23 +126,23 @@ Knowing which companies are using this library is important to help prioritize t The following companies, among others, use Powertools: -- [Alma Media](https://www.almamedia.fi) -- [AppYourself](https://appyourself.net) -- [Bailey Nelson](https://www.baileynelson.com.au) -- [Banxware](https://www.banxware.com) -- [Caylent](https://caylent.com/) -- [Certible](https://www.certible.com/) -- [Elva](https://elva-group.com) -- [Flyweight](https://flyweight.io/) -- [globaldatanet](https://globaldatanet.com/) -- [Guild](https://guild.com) -- [Hashnode](https://hashnode.com/) -- [LocalStack](https://localstack.cloud/) -- [Perfect Post](https://www.perfectpost.fr) -- [Sennder](https://sennder.com/) -- [tecRacer GmbH & Co. KG](https://www.tecracer.com/) -- [Trek10](https://www.trek10.com/) -- [WeSchool](https://www.weschool.com) +* [Alma Media](https://www.almamedia.fi) +* [AppYourself](https://appyourself.net) +* [Bailey Nelson](https://www.baileynelson.com.au) +* [Banxware](https://www.banxware.com) +* [Caylent](https://caylent.com/) +* [Certible](https://www.certible.com/) +* [Elva](https://elva-group.com) +* [Flyweight](https://flyweight.io/) +* [globaldatanet](https://globaldatanet.com/) +* [Guild](https://guild.com) +* [Hashnode](https://hashnode.com/) +* [LocalStack](https://localstack.cloud/) +* [Perfect Post](https://www.perfectpost.fr) +* [Sennder](https://sennder.com/) +* [tecRacer GmbH & Co. KG](https://www.tecracer.com/) +* [Trek10](https://www.trek10.com/) +* [WeSchool](https://www.weschool.com) ### Sharing your work diff --git a/packages/event-handler/package.json b/packages/event-handler/package.json index 204af16392..af00536867 100644 --- a/packages/event-handler/package.json +++ b/packages/event-handler/package.json @@ -18,7 +18,7 @@ "test:e2e": "echo \"Not implemented\"", "build:cjs": "tsc --build tsconfig.json && echo '{ \"type\": \"commonjs\" }' > lib/cjs/package.json", "build:esm": "tsc --build tsconfig.esm.json && echo '{ \"type\": \"module\" }' > lib/esm/package.json", - "build": "echo \"Not implemented\"", + "build": "npm run build:esm & npm run build:cjs", "lint": "biome lint .", "lint:fix": "biome check --write .", "prepack": "node ../../.github/scripts/release_patch_package_json.js ." @@ -27,19 +27,27 @@ "license": "MIT-0", "type": "module", "exports": { - ".": { + "./appsync-events": { "require": { - "types": "./lib/cjs/index.d.ts", - "default": "./lib/cjs/index.js" + "types": "./lib/cjs/appsync-events/index.d.ts", + "default": "./lib/cjs/appsync-events/index.js" }, "import": { - "types": "./lib/esm/index.d.ts", - "default": "./lib/esm/index.js" + "types": "./lib/esm/appsync-events/index.d.ts", + "default": "./lib/esm/appsync-events/index.js" + } + }, + "./types": { + "require": { + "types": "./lib/cjs/types/index.d.ts", + "default": "./lib/cjs/types/index.js" + }, + "import": { + "types": "./lib/esm/types/index.d.ts", + "default": "./lib/esm/types/index.js" } } }, - "types": "./lib/cjs/index.d.ts", - "main": "./lib/cjs/index.js", "files": [ "lib" ], @@ -63,6 +71,7 @@ "event", "handler", "nodejs", - "serverless" + "serverless", + "appsync-events" ] } diff --git a/packages/event-handler/src/appsync-events/AppSyncEventsResolver.ts b/packages/event-handler/src/appsync-events/AppSyncEventsResolver.ts new file mode 100644 index 0000000000..a9a3ff0f07 --- /dev/null +++ b/packages/event-handler/src/appsync-events/AppSyncEventsResolver.ts @@ -0,0 +1,204 @@ +import type { Context } from 'aws-lambda'; +import type { + AppSyncEventsPublishEvent, + AppSyncEventsSubscribeEvent, + OnPublishHandlerAggregateFn, + OnPublishHandlerFn, + OnSubscribeHandler, +} from '../types/appsync-events.js'; +import { Router } from './Router.js'; +import { UnauthorizedException } from './errors.js'; +import { isAppSyncEventsEvent, isAppSyncEventsPublishEvent } from './utils.js'; + +/** + * Resolver for AWS AppSync Events APIs. + * + * This resolver is designed to handle the `onPublish` and `onSubscribe` events + * from AWS AppSync Events APIs. It allows you to register handlers for these events + * and route them to the appropriate functions based on the event's path. + * + * @example + * ```ts + * import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; + * + * const app = new AppSyncEventsResolver(); + * + * app.onPublish('/foo', async (payload) => { + * // your business logic here + * return payload; + * }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + */ +class AppSyncEventsResolver extends Router { + /** + * Resolve the response based on the provided event and route handlers configured. + * + * @example + * ```ts + * import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; + * + * const app = new AppSyncEventsResolver(); + * + * app.onPublish('/foo', async (payload) => { + * // your business logic here + * return payload; + * }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * The method works also as class method decorator, so you can use it like this: + * + * @example + * ```ts + * import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; + * + * const app = new AppSyncEventsResolver(); + * + * class Lambda { + * ⁣@app.onPublish('/foo') + * async handleFoo(payload) { + * // your business logic here + * return payload; + * } + * + * async handler(event, context) { + * return app.resolve(event, context); + * } + * } + * + * const lambda = new Lambda(); + * export const handler = lambda.handler.bind(lambda); + * ``` + * + * @param event - The incoming event from AppSync Events + * @param context - The context object provided by AWS Lambda + */ + public async resolve(event: unknown, context: Context) { + if (!isAppSyncEventsEvent(event)) { + this.logger.warn( + 'Received an event that is not compatible with this resolver' + ); + return; + } + + if (isAppSyncEventsPublishEvent(event)) { + return await this.handleOnPublish(event, context); + } + return await this.handleOnSubscribe( + event as AppSyncEventsSubscribeEvent, + context + ); + } + + /** + * Handle the `onPublish` event. + * + * @param event - The incoming event from AppSync Events + * @param context - The context object provided by AWS Lambda + */ + protected async handleOnPublish( + event: AppSyncEventsPublishEvent, + context: Context + ) { + const { path } = event.info.channel; + const routeHandlerOptions = this.onPublishRegistry.resolve(path); + if (!routeHandlerOptions) { + return { events: event.events }; + } + const { handler, aggregate } = routeHandlerOptions; + if (aggregate) { + try { + return { + events: await (handler as OnPublishHandlerAggregateFn).apply(this, [ + event.events, + event, + context, + ]), + }; + } catch (error) { + this.logger.error(`An error occurred in handler ${path}`, error); + if (error instanceof UnauthorizedException) throw error; + return this.#formatErrorResponse(error); + } + } + return { + events: await Promise.all( + event.events.map(async (message) => { + const { id, payload } = message; + try { + const result = await (handler as OnPublishHandlerFn).apply(this, [ + payload, + event, + context, + ]); + return { + id, + payload: result, + }; + } catch (error) { + this.logger.error(`An error occurred in handler ${path}`, error); + return { + id, + ...this.#formatErrorResponse(error), + }; + } + }) + ), + }; + } + + /** + * Handle the `onSubscribe` event. + * + * After resolving the correct handler, we call it with the event and context. + * If the handler throws an error, we catch it and format the error response + * for a friendly output to the client. + * + * @param event - The incoming event from AppSync Events + * @param context - The context object provided by AWS Lambda + */ + protected async handleOnSubscribe( + event: AppSyncEventsSubscribeEvent, + context: Context + ) { + const { path } = event.info.channel; + const routeHandlerOptions = this.onSubscribeRegistry.resolve(path); + if (!routeHandlerOptions) { + return event.events; + } + const { handler } = routeHandlerOptions; + try { + return await (handler as OnSubscribeHandler).apply(this, [ + event, + context, + ]); + } catch (error) { + this.logger.error(`An error occurred in handler ${path}`, error); + if (error instanceof UnauthorizedException) throw error; + return this.#formatErrorResponse(error); + } + } + + /** + * Format the error response to be returned to the client. + * + * @param error - The error object + */ + #formatErrorResponse(error: unknown) { + if (error instanceof Error) { + return { + error: `${error.name} - ${error.message}`, + }; + } + return { + error: 'An unknown error occurred', + }; + } +} + +export { AppSyncEventsResolver }; diff --git a/packages/event-handler/src/appsync-events/RouteHandlerRegistry.ts b/packages/event-handler/src/appsync-events/RouteHandlerRegistry.ts new file mode 100644 index 0000000000..7632563d4d --- /dev/null +++ b/packages/event-handler/src/appsync-events/RouteHandlerRegistry.ts @@ -0,0 +1,157 @@ +import { LRUCache } from '@aws-lambda-powertools/commons/utils/lru-cache'; +import type { + GenericLogger, + RouteHandlerOptions, + RouteHandlerRegistryOptions, +} from '../types/appsync-events.js'; +import type { Router } from './Router.js'; + +/** + * Registry for storing route handlers for the `onPublish` and `onSubscribe` events in AWS AppSync Events APIs. + * + * This class should not be used directly unless you are implementing a custom router. + * Instead, use the {@link Router} class, which is the recommended way to register routes. + */ +class RouteHandlerRegistry { + /** + * A map of registered route handlers, keyed by their regular expression patterns. + */ + protected readonly resolvers: Map> = + new Map(); + /** + * A logger instance to be used for logging debug and warning messages. + */ + readonly #logger: GenericLogger; + /** + * The event type stored in the registry. + */ + readonly #eventType: 'onPublish' | 'onSubscribe'; + /** + * A cache for storing the resolved route handlers. + * This is used to improve performance by avoiding repeated regex matching. + */ + readonly #resolverCache: LRUCache> = + new LRUCache({ + maxSize: 100, + }); + /** + * A set of warning messages to avoid duplicate warnings. + */ + readonly #warningSet: Set = new Set(); + + public constructor(options: RouteHandlerRegistryOptions) { + this.#logger = options.logger; + this.#eventType = options.eventType ?? 'onPublish'; + } + + /** + * Register a route handler function for a specific path. + * + * A path should always have a namespace starting with `/`, for example `/default/*`. + * A path can have multiple namespaces, all separated by `/`, for example `/default/foo/bar`. + * Wildcards are allowed only at the end of the path, for example `/default/*` or `/default/foo/*`. + * + * If the path is already registered, the previous handler will be replaced and a warning will be logged. + * + * @param options - The options for the route handler + * @param options.path - The path of the event to be registered, default is `/default/*` + * @param options.handler - The handler function to be called when the event is received + * @param options.aggregate - Whether the route handler will send all the events to the route handler at once or one by one, default is `false` + */ + public register(options: RouteHandlerOptions): void { + const { path, handler, aggregate = false } = options; + this.#logger.debug( + `Registering ${this.#eventType} route handler for path '${path}' with aggregate '${aggregate}'` + ); + if (!RouteHandlerRegistry.isValidPath(path)) { + this.#logger.warn( + `The path '${path}' registered for ${this.#eventType} is not valid and will be skipped. A path should always have a namespace starting with '/'. A path can have multiple namespaces, all separated by '/'. Wildcards are allowed only at the end of the path.` + ); + return; + } + const regex = RouteHandlerRegistry.pathToRegexString(path); + if (this.resolvers.has(regex)) { + this.#logger.warn( + `A route handler for path '${path}' is already registered for ${this.#eventType}. The previous handler will be replaced.` + ); + } + this.resolvers.set(regex, { + path, + handler, + aggregate, + }); + } + + /** + * Resolve the handler for a specific path. + * + * Find the most specific handler for the given path, which is the longest one minus the wildcard. + * If no handler is found, it returns `undefined`. + * + * Examples of specificity: + * - `'/default/v1/users'` -> score: 14 (len=14, wildcards=0) + * - `'/default/v1/users/*'` -> score: 14 (len=15, wildcards=1) + * - `'/default/v1/*'` -> score: 8 (len=9, wildcards=1) + * - `'/*'` -> score: 0 (len=1, wildcards=1) + * + * @param path - The path of the event to be resolved + */ + public resolve(path: string): RouteHandlerOptions | undefined { + if (this.#resolverCache.has(path)) { + return this.#resolverCache.get(path); + } + this.#logger.debug(`Resolving handler for path '${path}'`); + let mostSpecificHandler = undefined; + let mostSpecificRouteLength = 0; + for (const [key, value] of this.resolvers.entries()) { + if (new RegExp(key).test(path)) { + const specificityLength = + value.path.length - (value.path.endsWith('*') ? 1 : 0); + if (specificityLength > mostSpecificRouteLength) { + mostSpecificRouteLength = specificityLength; + mostSpecificHandler = value; + } + } + } + if (mostSpecificHandler === undefined) { + if (!this.#warningSet.has(path)) { + this.#logger.warn( + `No route handler found for path '${path}' registered for ${this.#eventType}.` + ); + this.#warningSet.add(path); + } + return undefined; + } + this.#resolverCache.add(path, mostSpecificHandler); + return mostSpecificHandler; + } + + /** + * Check if the path is valid. + * + * A path should always have a namespace starting with `/`, for example `/default/*`. + * A path can have multiple namespaces, all separated by `/`, for example `/default/foo/bar`. + * Wildcards are allowed only at the end of the path, for example `/default/*` or `/default/foo/*`. + * + * @param path - The path of the event to be registered + */ + static isValidPath(path: string): boolean { + if (path === '/*') return true; + const pathRegex = /^\/([^\/\*]+)(\/[^\/\*]+)*(\/\*)?$/; + return pathRegex.test(path); + } + + /** + * Convert a path to a regular expression string. + * + * In doing so, it escapes all special characters and replaces the wildcard `*` with `.*`. + * + * @param path - The path to be converted to a regex string + */ + static pathToRegexString(path: string): string { + const escapedPath = path.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1'); + return `^${escapedPath.replace(/\\\*/g, '.*')}$`; + } +} + +export { RouteHandlerRegistry }; diff --git a/packages/event-handler/src/appsync-events/Router.ts b/packages/event-handler/src/appsync-events/Router.ts new file mode 100644 index 0000000000..f9402b53c8 --- /dev/null +++ b/packages/event-handler/src/appsync-events/Router.ts @@ -0,0 +1,286 @@ +import { EnvironmentVariablesService } from '@aws-lambda-powertools/commons'; +import { isRecord } from '@aws-lambda-powertools/commons/typeutils'; +import type { + GenericLogger, + OnPublishHandler, + OnSubscribeHandler, + RouteOptions, + RouterOptions, +} from '../types/appsync-events.js'; +import { RouteHandlerRegistry } from './RouteHandlerRegistry.js'; + +/** + * Class for registering routes for the `onPublish` and `onSubscribe` events in AWS AppSync Events APIs. + */ +class Router { + /** + * A map of registered routes for the `onPublish` event, keyed by their paths. + */ + protected readonly onPublishRegistry: RouteHandlerRegistry; + /** + * A map of registered routes for the `onSubscribe` event, keyed by their paths. + */ + protected readonly onSubscribeRegistry: RouteHandlerRegistry; + /** + * A logger instance to be used for logging debug, warning, and error messages. + * + * When no logger is provided, we'll only log warnings and errors using the global `console` object. + */ + protected readonly logger: Pick; + /** + * Whether the router is running in development mode. + */ + protected readonly isDev: boolean = false; + /** + * The environment variables service instance. + */ + protected readonly envService: EnvironmentVariablesService; + + public constructor(options?: RouterOptions) { + this.envService = new EnvironmentVariablesService(); + const alcLogLevel = this.envService.get('AWS_LAMBDA_LOG_LEVEL'); + this.logger = options?.logger ?? { + debug: alcLogLevel === 'DEBUG' ? console.debug : () => undefined, + error: console.error, + warn: console.warn, + }; + this.onPublishRegistry = new RouteHandlerRegistry({ + logger: this.logger, + eventType: 'onPublish', + }); + this.onSubscribeRegistry = new RouteHandlerRegistry({ + logger: this.logger, + eventType: 'onSubscribe', + }); + this.isDev = this.envService.isDevMode(); + } + + /** + * Register a handler function for the `onPublish` event. + * + * When setting a handler, the path must be a string with a namespace starting with `/`, for example `/default/*`. + * A path can have multiple namespaces, all separated by `/`, for example `/default/foo/bar`. + * Wildcards are allowed only at the end of the path, for example `/default/*` or `/default/foo/*`. + * + * @example + * ```ts + * import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; + * + * const app = new AppSyncEventsResolver(); + * + * app.onPublish('/foo', async (payload) => { + * // your business logic here + * return payload; + * }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * By default, the handler will be called for each event message received by the AWS Lambda function. For example, if + * you receive 10 events, the handler will be called 10 times in parallel. When the handler is called, the first + * parameter is the message `payload`, which is an object containing the message payload sent by the publisher, for + * example: + * + * @example + * ```json + * { + * "foo": "bar", + * } + * ``` + * + * If your function throws an error, we catch it and format the error response for a friendly output to the client corresponding to the + * event that caused the error. In this case, that specific event will be dropped, but the other events will + * still be processed. + * + * **Process all events at once** + * + * If you want to receive all the events at once, you can set the `aggregate` option to `true`. In this case, the + * handler will be called only once with an array of events and you are responsible for handling the + * events in your function and returning a list of events to be sent back to AWS AppSync. + * + * @example + * ```ts + * import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; + * + * const app = new AppSyncEventsResolver(); + * + * app.onPublish('/foo', async (payload) => { + * // your business logic here + * return payload; + * }, { + * aggregate: true, + * }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * When the handler is called, the first parameter is an array of messages, which is an array of objects containing + * the message payload sent by the publisher and their `id`, while the second and third parameters are optional and + * are the original Lambda function event and context. Below is an example of the first parameter: + * + * @example + * ```json + * [ + * { + * "id": "123456", + * "payload": { + * "foo": "bar", + * } + * }, + * { + * "id": "654321", + * "payload": { + * } + * } + * ] + * ``` + * + * When working with `aggregate` enabled, if your function throws an error, we catch it and format the error + * response to be sent back to AppSync. This helps the client to understand what went wrong and handle the error accordingly. + * + * It's important to note that if your function throws an error, the entire batch of events will be dropped. + * + * The method works also as class method decorator, so you can use it like this: + * + * @example + * ```ts + * import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; + * + * const app = new AppSyncEventsResolver(); + * + * class Lambda { + * ⁣@app.onPublish('/foo') + * async handleFoo(payload) { + * // your business logic here + * return payload; + * } + * + * async handler(event, context) { + * return app.resolve(event, context); + * } + * } + * + * const lambda = new Lambda(); + * export const handler = lambda.handler.bind(lambda); + * ``` + * + * @param path - The path of the event to be registered, i.e. `/namespace/channel` + * @param handler - The handler function to be called when the event is received + * @param options - The options for the route handler + * @param options.aggregate - Whether the resolver will send all the events to the resolver at once or one by one + */ + public onPublish( + path: string, + handler: OnPublishHandler, + options?: RouteOptions + ): void; + public onPublish( + path: string, + options?: RouteOptions + ): MethodDecorator; + public onPublish( + path: string, + handler?: OnPublishHandler | RouteOptions, + options?: RouteOptions + ): MethodDecorator | undefined { + if (handler && typeof handler === 'function') { + this.onPublishRegistry.register({ + path, + handler, + aggregate: (options?.aggregate ?? false) as T, + }); + return; + } + + return (_target, _propertyKey, descriptor: PropertyDescriptor) => { + const routeOptions = isRecord(handler) ? handler : options; + this.onPublishRegistry.register({ + path, + handler: descriptor.value, + aggregate: (routeOptions?.aggregate ?? false) as T, + }); + return descriptor; + }; + } + + /** + * Register a handler function for the `onSubscribe` event. + * + * When setting a handler, the path must be a string with a namespace starting with `/`, for example `/default/*`. + * A path can have multiple namespaces, all separated by `/`, for example `/default/foo/bar`. + * Wildcards are allowed only at the end of the path, for example `/default/*` or `/default/foo/*`. + * + * @example + * ```ts + * import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; + * + * const app = new AppSyncEventsResolver(); + * + * app.onSubscribe('/foo', async (event) => { + * // your business logic here + * }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * The first parameter of the handler function is the original AWS AppSync event and the second parameter is the + * AWS Lambda context. + * + * If your function throws an error, we catch it and format the error response to be sent back to AppSync. This + * helps the client to understand what went wrong and handle the error accordingly, however it still prevents + * the subscription from being established. + * + * The method works also as class method decorator, so you can use it like this: + * + * @example + * ```ts + * import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events'; + * + * const app = new AppSyncEventsResolver(); + * + * class Lambda { + * ⁣@app.onSubscribe('/foo') + * async handleFoo(event) { + * // your business logic here + * } + * + * async handler(event, context) { + * return app.resolve(event, context); + * } + * } + * + * const lambda = new Lambda(); + * export const handler = lambda.handler.bind(lambda); + * ``` + * + * @param path - The path of the event to be registered, i.e. `/namespace/channel` + * @param handler - The handler function to be called when the event is received + */ + public onSubscribe(path: string, handler: OnSubscribeHandler): void; + public onSubscribe(path: string): MethodDecorator; + public onSubscribe( + path: string, + handler?: OnSubscribeHandler + ): MethodDecorator | undefined { + if (handler && typeof handler === 'function') { + this.onSubscribeRegistry.register({ + path, + handler, + }); + return; + } + + return (_target, _propertyKey, descriptor: PropertyDescriptor) => { + this.onSubscribeRegistry.register({ + path, + handler: descriptor.value, + }); + return descriptor; + }; + } +} + +export { Router }; diff --git a/packages/event-handler/src/appsync-events/errors.ts b/packages/event-handler/src/appsync-events/errors.ts new file mode 100644 index 0000000000..0d1dcb238e --- /dev/null +++ b/packages/event-handler/src/appsync-events/errors.ts @@ -0,0 +1,31 @@ +/** + * Error to be thrown to communicate the subscription is unauthorized. + * + * When this error is thrown, the client will receive a 40x error code + * and the subscription will be closed. + * + * @example + * ```ts + * import { + * AppSyncEventsResolver, + * UnauthorizedException, + * } from '@aws-lambda-powertools/event-handler/appsync-events'; + * + * const app = new AppSyncEventsResolver(); + * + * app.onPublish('/foo', async (payload) => { + * throw new UnauthorizedException('Unauthorized to publish to channel /foo'); + * }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + */ +class UnauthorizedException extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'UnauthorizedException'; + } +} + +export { UnauthorizedException }; diff --git a/packages/event-handler/src/appsync-events/index.ts b/packages/event-handler/src/appsync-events/index.ts new file mode 100644 index 0000000000..8b25ae13be --- /dev/null +++ b/packages/event-handler/src/appsync-events/index.ts @@ -0,0 +1,3 @@ +export { AppSyncEventsResolver } from './AppSyncEventsResolver.js'; +export { Router } from './Router.js'; +export { UnauthorizedException } from './errors.js'; diff --git a/packages/event-handler/src/appsync-events/utils.ts b/packages/event-handler/src/appsync-events/utils.ts new file mode 100644 index 0000000000..c3d8943782 --- /dev/null +++ b/packages/event-handler/src/appsync-events/utils.ts @@ -0,0 +1,67 @@ +import { isRecord, isString } from '@aws-lambda-powertools/commons/typeutils'; +import type { + AppSyncEventsEvent, + AppSyncEventsPublishEvent, +} from '../types/appsync-events.js'; + +/** + * Type guard to check if the provided event is an AppSync Events event. + * + * We use this function to ensure that the event is an object and has the required properties + * without adding any dependency. + * + * @param event - The incoming event to check + */ +const isAppSyncEventsEvent = (event: unknown): event is AppSyncEventsEvent => { + if (typeof event !== 'object' || event === null || !isRecord(event)) { + return false; + } + return ( + 'identity' in event && + 'result' in event && + isRecord(event.request) && + isRecord(event.request.headers) && + 'domainName' in event.request && + 'error' in event && + 'prev' in event && + isRecord(event.stash) && + Array.isArray(event.outErrors) && + 'events' in event && + isRecord(event.info) && + isRecord(event.info.channel) && + 'path' in event.info.channel && + isString(event.info.channel.path) && + 'segments' in event.info.channel && + Array.isArray(event.info.channel.segments) && + event.info.channel.segments.every((segment) => isString(segment)) && + isRecord(event.info.channelNamespace) && + 'name' in event.info.channelNamespace && + isString(event.info.channelNamespace.name) && + 'operation' in event.info && + /* v8 ignore next */ + (event.info.operation === 'PUBLISH' || event.info.operation === 'SUBSCRIBE') + ); +}; + +/** + * Type guard to check if the provided event is an AppSync Events publish event. + * + * We use this function to ensure that the event is an object and has the required properties + * without adding any dependency. + * + * @param event - The incoming event to check + */ +const isAppSyncEventsPublishEvent = ( + event: AppSyncEventsEvent +): event is AppSyncEventsPublishEvent => { + return ( + event.info.operation === 'PUBLISH' && + Array.isArray(event.events) && + event.events.every( + (e) => + isRecord(e) && 'payload' in e && 'id' in e && typeof e.id === 'string' + ) + ); +}; + +export { isAppSyncEventsEvent, isAppSyncEventsPublishEvent }; diff --git a/packages/event-handler/src/index.ts b/packages/event-handler/src/index.ts deleted file mode 100644 index 9d87720cc5..0000000000 --- a/packages/event-handler/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const foo = () => true; diff --git a/packages/event-handler/src/types/appsync-events.ts b/packages/event-handler/src/types/appsync-events.ts new file mode 100644 index 0000000000..87be99e624 --- /dev/null +++ b/packages/event-handler/src/types/appsync-events.ts @@ -0,0 +1,300 @@ +import type { Context } from 'aws-lambda'; +import type { RouteHandlerRegistry } from '../appsync-events/RouteHandlerRegistry.js'; +import type { Router } from '../appsync-events/Router.js'; + +// #region Shared + +// biome-ignore lint/suspicious/noExplicitAny: We intentionally use `any` here to represent any type of data and keep the logger is as flexible as possible. +type Anything = any; + +/** + * Interface for a generic logger object. + */ +type GenericLogger = { + trace?: (...content: Anything[]) => void; + debug: (...content: Anything[]) => void; + info?: (...content: Anything[]) => void; + warn: (...content: Anything[]) => void; + error: (...content: Anything[]) => void; +}; + +// #region OnPublish fn + +type OnPublishHandlerFn = ( + payload: Anything, + event: AppSyncEventsPublishEvent, + context: Context +) => Promise; + +type OnPublishHandlerSyncFn = ( + payload: Anything, + event: AppSyncEventsPublishEvent, + context: Context +) => unknown; + +type OnPublishHandlerAggregateFn = ( + events: Array<{ + payload: Anything; + id: string; + }>, + event: AppSyncEventsPublishEvent, + context: Context +) => Promise; + +type OnPublishHandlerSyncAggregateFn = ( + events: Array<{ + payload: Anything; + id: string; + }>, + event: AppSyncEventsPublishEvent, + context: Context +) => unknown[]; + +type OnPublishAggregateOutput = Array<{ + payload?: T; + error?: string; + id: string; +}>; + +type OnPublishEventPayload = { + payload?: T; + error?: string; + id: string; +}; + +type OnPublishOutput = { + events: Array>; +}; + +/** + * Handler type for onPublish events. + * + * @template T - Boolean indicating whether this is an aggregate handler + * - When `true`, handler processes multiple events at once `(events[], event, context)` + * - When `false` or `undefined`, handler processes one event at a time `(payload, event, context)` + */ +type OnPublishHandler = + T extends true + ? OnPublishHandlerAggregateFn | OnPublishHandlerSyncAggregateFn + : OnPublishHandlerFn | OnPublishHandlerSyncFn; + +// #region OnSubscribe fn + +type OnSubscribeSyncHandlerFn = ( + event: AppSyncEventsSubscribeEvent, + context: Context +) => unknown; + +type OnSubscribeHandlerFn = ( + event: AppSyncEventsSubscribeEvent, + context: Context +) => Promise; + +type OnSubscribeHandler = OnSubscribeSyncHandlerFn | OnSubscribeHandlerFn; + +// #region Resolver registry + +/** + * Options for the {@link RouteHandlerRegistry} class + */ +type RouteHandlerRegistryOptions = { + /** + * A logger instance to be used for logging debug, warning, and error messages. + * + * When no logger is provided, we'll only log warnings and errors using the global `console` object. + */ + logger: GenericLogger; + /** + * Event type stored in the registry + * @default 'onPublish' + */ + eventType?: 'onPublish' | 'onSubscribe'; +}; + +/** + * Options for registering a resolver event + * + * @property path - The path of the event to be registered + * @property handler - The handler function to be called when the event is received + * @property aggregate - Whether the route handler will send all the events to the route handler at once or one by one, default is `false` + */ +type RouteHandlerOptions = { + /** + * The handler function to be called when the event is received + */ + handler: OnPublishHandler | OnSubscribeHandler; + /** + * Whether the route handler will send all the events to the route handler at once or one by one + * @default false + */ + aggregate?: T; + /** + * The path of the event to be registered + */ + path: string; +}; + +// #region Router + +/** + * Options for the {@link Router} class + */ +type RouterOptions = { + /** + * A logger instance to be used for logging debug, warning, and error messages. + * + * When no logger is provided, we'll only log warnings and errors using the global `console` object. + */ + logger?: GenericLogger; +}; + +/** + * Options for registering a route + */ +type RouteOptions = { + /** + * Whether the resolver will send all the events to the resolver at once or one by one + * @default false + */ + aggregate?: T; +}; + +// #region Events + +type AppSyncEventsEvent = { + identity: null; + result: null; + request: { + headers: Record; + domainName: null; + }; + error: null; + prev: null; + stash: Record; + outErrors: unknown[]; + events: unknown; + info: { + channel: { + path: string; + segments: Array; + }; + channelNamespace: { + name: string; + }; + operation: 'PUBLISH' | 'SUBSCRIBE'; + }; +}; + +/** + * Event type for AppSync Events publish events. + * + * @example + * ```json + * { + * "identity": null, + * "result": null, + * "request": { + * "headers": { + * "header1": "value1", + * }, + * "domainName": "example.com" + * }, + * "info": { + * "channel": { + * "path": "/default/foo", + * "segments": ["default", "foo"] + * }, + * "channelNamespace": { + * "name": "default" + * }, + * "operation": "PUBLISH" + * }, + * "error": null, + * "prev": null, + * "stash": {}, + * "outErrors": [], + * "events": [ + * { + * "payload": { + * "key": "value" + * }, + * "id": "12345" + * }, + * { + * "payload": { + * "key2": "value2" + * }, + * "id": "67890" + * } + * ] + * } + * ``` + */ +type AppSyncEventsPublishEvent = AppSyncEventsEvent & { + info: { + operation: 'PUBLISH'; + }; + events: Array<{ + payload: unknown; + id: string; + }>; +}; + +/** + * Event type for AppSync Events subscribe events. + * + * @example + * ```json + * { + * "identity": null, + * "result": null, + * "request": { + * "headers": { + * "header1": "value1", + * }, + * "domainName": "example.com" + * }, + * "info": { + * "channel": { + * "path": "/default/foo", + * "segments": ["default", "foo"] + * }, + * "channelNamespace": { + * "name": "default" + * }, + * "operation": "SUBSCRIBE" + * }, + * "error": null, + * "prev": null, + * "stash": {}, + * "outErrors": [], + * "events": null + * } + * ``` + */ +type AppSyncEventsSubscribeEvent = AppSyncEventsEvent & { + info: { + operation: 'SUBSCRIBE'; + }; + events: null; +}; + +export type { + GenericLogger, + RouteHandlerRegistryOptions, + RouteHandlerOptions, + RouterOptions, + RouteOptions, + AppSyncEventsEvent, + AppSyncEventsPublishEvent, + AppSyncEventsSubscribeEvent, + OnPublishHandler, + OnPublishHandlerFn, + OnPublishHandlerSyncFn, + OnPublishHandlerSyncAggregateFn, + OnPublishHandlerAggregateFn, + OnSubscribeHandler, + OnPublishAggregateOutput, + OnPublishEventPayload, + OnPublishOutput, +}; diff --git a/packages/event-handler/src/types/index.ts b/packages/event-handler/src/types/index.ts new file mode 100644 index 0000000000..424189e05c --- /dev/null +++ b/packages/event-handler/src/types/index.ts @@ -0,0 +1,10 @@ +export type { + AppSyncEventsEvent, + AppSyncEventsPublishEvent, + AppSyncEventsSubscribeEvent, + OnPublishAggregateOutput, + OnPublishEventPayload, + OnPublishOutput, + RouteOptions, + RouterOptions, +} from './appsync-events.js'; diff --git a/packages/event-handler/tests/events/onPublish.json b/packages/event-handler/tests/events/onPublish.json new file mode 100644 index 0000000000..0e7150930b --- /dev/null +++ b/packages/event-handler/tests/events/onPublish.json @@ -0,0 +1,47 @@ +{ + "identity": null, + "result": null, + "request": { + "headers": { + "key": "value" + }, + "domainName": null + }, + "info": { + "channel": { + "path": "/request/channel", + "segments": [ + "request", + "channel" + ] + }, + "channelNamespace": { + "name": "request" + }, + "operation": "PUBLISH" + }, + "error": null, + "prev": null, + "stash": {}, + "outErrors": [], + "events": [ + { + "payload": { + "event_1": "data_1" + }, + "id": "5f7dfbd1-b8ff-4c20-924e-23b42db467a0" + }, + { + "payload": { + "event_2": "data_2" + }, + "id": "ababdf65-a3e6-4c1d-acd3-87466eab433c" + }, + { + "payload": { + "event_3": "data_3" + }, + "id": "8bb2983a-0967-45a0-8243-0aeb8c83d80e" + } + ] +} \ No newline at end of file diff --git a/packages/event-handler/tests/helpers/factories.ts b/packages/event-handler/tests/helpers/factories.ts new file mode 100644 index 0000000000..a439da81ab --- /dev/null +++ b/packages/event-handler/tests/helpers/factories.ts @@ -0,0 +1,77 @@ +const onPublishEventFactory = ( + events: Array<{ payload: unknown; id: string }> = [ + { + payload: { + event_1: 'data_1', + }, + id: '5f7dfbd1-b8ff-4c20-924e-23b42db467a0', + }, + { + payload: { + event_2: 'data_2', + }, + id: 'ababdf65-a3e6-4c1d-acd3-87466eab433c', + }, + { + payload: { + event_3: 'data_3', + }, + id: '8bb2983a-0967-45a0-8243-0aeb8c83d80e', + }, + ], + channel = { + path: '/request/channel', + segments: ['request', 'channel'], + } +) => ({ + identity: null, + result: null, + request: { + headers: { + key: 'value', + }, + domainName: null, + }, + info: { + channel, + channelNamespace: { + name: channel.segments[0], + }, + operation: 'PUBLISH', + }, + error: null, + prev: null, + stash: {}, + outErrors: [], + events, +}); + +const onSubscribeEventFactory = ( + channel = { + path: '/request/channel', + segments: ['request', 'channel'], + } +) => ({ + identity: null, + result: null, + request: { + headers: { + key: 'value', + }, + domainName: null, + }, + info: { + channel, + channelNamespace: { + name: channel.segments[0], + }, + operation: 'PUBLISH', + }, + error: null, + prev: null, + stash: {}, + outErrors: [], + events: null, +}); + +export { onPublishEventFactory, onSubscribeEventFactory }; diff --git a/packages/event-handler/tests/unit/AppSyncEventsResolver.test.ts b/packages/event-handler/tests/unit/AppSyncEventsResolver.test.ts new file mode 100644 index 0000000000..f99c764b45 --- /dev/null +++ b/packages/event-handler/tests/unit/AppSyncEventsResolver.test.ts @@ -0,0 +1,307 @@ +import context from '@aws-lambda-powertools/testing-utils/context'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + AppSyncEventsResolver, + UnauthorizedException, +} from '../../src/appsync-events/index.js'; +import { + onPublishEventFactory, + onSubscribeEventFactory, +} from '../helpers/factories.js'; + +describe('Class: AppSyncEventsResolver', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('logs a warning and returns early if the event is not compatible', async () => { + // Prepare + const app = new AppSyncEventsResolver({ logger: console }); + + // Act + const result = await app.resolve(null, context); + + // Assess + expect(console.warn).toHaveBeenCalledWith( + 'Received an event that is not compatible with this resolver' + ); + expect(result).toBeUndefined(); + }); + + it('returns the events unmodified if there are no onPublish handlers', async () => { + // Prepare + const app = new AppSyncEventsResolver({ logger: console }); + + // Act + const result = await app.resolve( + onPublishEventFactory([ + { + id: '1', + payload: 'foo', + }, + { + id: '2', + payload: 'bar', + }, + ]), + context + ); + + // Assess + expect(console.warn).toHaveBeenCalled(); + expect(result).toEqual({ + events: [ + { + id: '1', + payload: 'foo', + }, + { + id: '2', + payload: 'bar', + }, + ], + }); + }); + + it('returns null if there are no onSubscribe handlers', async () => { + // Prepare + const app = new AppSyncEventsResolver({ logger: console }); + + // Act + const result = await app.resolve(onSubscribeEventFactory(), context); + + // Assess + expect(console.warn).toHaveBeenCalled(); + expect(result).toEqual(null); + }); + + it('returns the response of the onSubscribe handler', async () => { + // Prepare + const app = new AppSyncEventsResolver({ logger: console }); + app.onSubscribe('/foo', async () => true); + + // Act + const result = await app.resolve( + onSubscribeEventFactory({ path: '/foo', segments: ['foo'] }), + context + ); + + // Assess + expect(result).toBe(true); + }); + + it.each([ + { + type: 'base error', + error: new Error('Error in handler'), + message: 'Error - Error in handler', + }, + { + type: 'syntax error', + error: new SyntaxError('Syntax error in handler'), + message: 'SyntaxError - Syntax error in handler', + }, + { + type: 'unknown error', + error: 'foo', + message: 'An unknown error occurred', + }, + ])( + 'formats the error thrown by the onSubscribe handler $type', + async ({ error, message }) => { + // Prepare + const app = new AppSyncEventsResolver({ logger: console }); + app.onSubscribe('/foo', async () => { + throw error; + }); + + // Act + const result = await app.resolve( + onSubscribeEventFactory({ path: '/foo', segments: ['foo'] }), + context + ); + + // Assess + expect(result).toEqual({ + error: message, + }); + } + ); + + it('throws an UnauthorizedException when thrown by the handler', async () => { + // Prepare + const app = new AppSyncEventsResolver({ logger: console }); + app.onSubscribe('/foo', async () => { + throw new UnauthorizedException('nah'); + }); + + // Act & Assess + await expect( + app.resolve( + onSubscribeEventFactory({ path: '/foo', segments: ['foo'] }), + context + ) + ).rejects.toThrow(UnauthorizedException); + }); + + it('returns the response of the onPublish handler', async () => { + // Prepare + const app = new AppSyncEventsResolver({ logger: console }); + app.onPublish('/foo', async (payload) => { + if (payload === 'foo') { + return true; + } + throw new Error('Error in handler'); + }); + + // Act + const result = await app.resolve( + onPublishEventFactory( + [ + { + id: '1', + payload: 'foo', + }, + { + id: '2', + payload: 'bar', + }, + ], + { path: '/foo', segments: ['foo'] } + ), + context + ); + + // Assess + expect(result).toEqual({ + events: [ + { + id: '1', + payload: true, + }, + { + id: '2', + error: 'Error - Error in handler', + }, + ], + }); + }); + + it('calls the onPublish handler with aggregate set to true', async () => { + // Prepare + const app = new AppSyncEventsResolver({ logger: console }); + app.onPublish( + '/foo', + async (payloads) => { + return payloads.map((payload) => ({ + id: payload.id, + payload: true, + })); + }, + { aggregate: true } + ); + + // Act + const result = await app.resolve( + onPublishEventFactory( + [ + { + id: '1', + payload: 'foo', + }, + { + id: '2', + payload: 'bar', + }, + ], + { + path: '/foo', + segments: ['foo'], + } + ), + context + ); + + // Assess + expect(result).toEqual({ + events: [ + { + id: '1', + payload: true, + }, + { + id: '2', + payload: true, + }, + ], + }); + }); + + it('formats the error thrown by an aggregate onPublish handler', async () => { + // Prepare + const app = new AppSyncEventsResolver({ logger: console }); + app.onPublish( + '/foo', + async () => { + throw new Error('Error in handler'); + }, + { aggregate: true } + ); + + // Act + const result = await app.resolve( + onPublishEventFactory(undefined, { + path: '/foo', + segments: ['foo'], + }), + context + ); + + // Assess + expect(result).toEqual({ + error: 'Error - Error in handler', + }); + }); + + it('throws an UnauthorizedException when thrown by the aggregate onPublish handler', async () => { + // Prepare + const app = new AppSyncEventsResolver({ logger: console }); + app.onPublish( + '/foo', + async () => { + throw new UnauthorizedException('nah'); + }, + { aggregate: true } + ); + + // Act & Assess + await expect( + app.resolve( + onPublishEventFactory(undefined, { path: '/foo', segments: ['foo'] }), + context + ) + ).rejects.toThrow(UnauthorizedException); + expect(console.error).toHaveBeenCalled(); + }); + + it('logs the error even if the logger is not provided', async () => { + // Prepare + const app = new AppSyncEventsResolver(); + app.onPublish( + '/foo', + async () => { + throw new Error('Error in handler'); + }, + { aggregate: true } + ); + + // Act + await app.resolve( + onPublishEventFactory(undefined, { path: '/foo', segments: ['foo'] }), + context + ); + + // Assess + expect(console.error).toHaveBeenCalled(); + }); +}); diff --git a/packages/event-handler/tests/unit/RouteHandlerRegistry.test.ts b/packages/event-handler/tests/unit/RouteHandlerRegistry.test.ts new file mode 100644 index 0000000000..fc0431967b --- /dev/null +++ b/packages/event-handler/tests/unit/RouteHandlerRegistry.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { RouteHandlerRegistry } from '../../src/appsync-events/RouteHandlerRegistry.js'; +import type { RouteHandlerOptions } from '../../src/types/appsync-events.js'; + +describe('Class: RouteHandlerRegistry', () => { + class MockRouteHandlerRegistry extends RouteHandlerRegistry { + public declare resolvers: Map>; + } + + const getRegistry = () => new MockRouteHandlerRegistry({ logger: console }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + { path: '/foo', expected: '^\\/foo$' }, + { path: '/*', expected: '^\\/.*$' }, + { path: '/foo/*', expected: '^\\/foo\\/.*$' }, + ])('registers a route handler for a path $path', ({ path, expected }) => { + // Prepare + const registry = getRegistry(); + + // Act + registry.register({ + path, + handler: vi.fn(), + aggregate: false, + }); + + // Assess + expect(registry.resolvers.size).toBe(1); + expect(registry.resolvers.get(expected)).toBeDefined(); + }); + + it('logs a warning and replaces the previous handler if the path is already registered', () => { + // Prepare + const registry = getRegistry(); + const originalHandler = vi.fn(); + const otherHandler = vi.fn(); + + // Act + registry.register({ + path: '/foo', + handler: originalHandler, + aggregate: true, + }); + registry.register({ + path: '/foo', + handler: otherHandler, + }); + + // Assess + expect(registry.resolvers.size).toBe(1); + expect(registry.resolvers.get('^\\/foo$')).toEqual({ + path: '/foo', + handler: otherHandler, + aggregate: false, + }); + expect(console.warn).toHaveBeenCalledWith( + `A route handler for path '/foo' is already registered for onPublish. The previous handler will be replaced.` + ); + }); + + it('logs a warning and skips registration if the path is not valid', () => { + // Prepare + const registry = getRegistry(); + + // Act + registry.register({ + path: 'invalid-path', + handler: vi.fn(), + aggregate: false, + }); + + // Assess + expect(registry.resolvers.size).toBe(0); + expect(console.warn).toHaveBeenCalledWith( + `The path 'invalid-path' registered for onPublish is not valid and will be skipped. A path should always have a namespace starting with '/'. A path can have multiple namespaces, all separated by '/'. Wildcards are allowed only at the end of the path.` + ); + }); + + it.each([ + { path: '/foo', expected: true }, + { path: '/foo/*', expected: true }, + { path: '/*', expected: true }, + { path: '/foo/bar', expected: true }, + { path: '/foo/bar/*', expected: true }, + { path: '/foo//bar', expected: false }, + { path: '/foo/bar//', expected: false }, + { path: '/foo/bar/*/baz', expected: false }, + { path: 'invalid-path', expected: false }, + ])('correctly validates paths $path', ({ path, expected }) => { + // Act + const isValid = MockRouteHandlerRegistry.isValidPath(path); + + // Assess + expect(isValid).toBe(expected); + }); + + it.each([ + { + paths: ['/foo', '/foo/*', '/*'], + event: '/foo/bar', + expected: '/foo/*', + }, + { + paths: ['/foo', '/foo/*', '/*'], + event: '/bar', + expected: '/*', + }, + { + paths: ['/foo', '/foo/*', '/*'], + event: '/foo', + expected: '/foo', + }, + { + paths: ['/foo/*', '/*'], + event: '/foo/bar', + expected: '/foo/*', + }, + { + paths: ['/*'], + event: '/bar', + expected: '/*', + }, + { + paths: ['/*'], + event: '/foo/bar', + expected: '/*', + }, + { + paths: ['/foo/bar'], + event: '/foo/bar/baz', + expected: undefined, + }, + ])('resolves the most specific path $event', ({ paths, event, expected }) => { + // Prepare + const registry = getRegistry(); + for (const path of paths) { + registry.register({ + path, + handler: vi.fn(), + }); + } + + // Act + const resolved = registry.resolve(event); + + // Assess + expect(resolved?.path).toBe(expected); + }); + + it('returns the cached route handler if already evaluated', () => { + // Prepare + const registry = getRegistry(); + registry.register({ + path: '/foo', + handler: vi.fn(), + aggregate: false, + }); + + // Act + registry.resolve('/foo'); + registry.resolve('/foo'); + + // Assess + expect(console.debug).toHaveBeenCalledTimes(2); // once for registration, once for resolution + expect(console.debug).toHaveBeenLastCalledWith( + `Resolving handler for path '/foo'` + ); + }); +}); diff --git a/packages/event-handler/tests/unit/Router.test.ts b/packages/event-handler/tests/unit/Router.test.ts new file mode 100644 index 0000000000..90fa2492a1 --- /dev/null +++ b/packages/event-handler/tests/unit/Router.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Router } from '../../src/appsync-events/index.js'; + +describe('Class: Router', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('registers resolvers using the functional approach', () => { + // Prepare + const router = new Router({ logger: console }); + const foo = vi.fn(() => [true]); + const bar = vi.fn(async () => true); + + // Act + router.onPublish('/foo', foo, { aggregate: true }); + router.onSubscribe('/bar', bar); + + // Assess + expect(console.debug).toHaveBeenNthCalledWith( + 1, + `Registering onPublish route handler for path '/foo' with aggregate 'true'` + ); + expect(console.debug).toHaveBeenNthCalledWith( + 2, + `Registering onSubscribe route handler for path '/bar' with aggregate 'false'` + ); + }); + + it('registers resolvers using the decorator pattern', () => { + // Prepare + const router = new Router({ logger: console }); + + // Act + class Lambda { + readonly prop = 'value'; + + @router.onPublish('/foo') + public foo() { + return `${this.prop} foo`; + } + + @router.onSubscribe('/bar') + public bar() { + return `${this.prop} bar`; + } + + @router.onPublish('/baz/*', { aggregate: true }) + public baz() { + return `${this.prop} baz`; + } + } + const lambda = new Lambda(); + const res1 = lambda.foo(); + const res2 = lambda.bar(); + const res3 = lambda.baz(); + + // Assess + expect(console.debug).toHaveBeenNthCalledWith( + 1, + `Registering onPublish route handler for path '/foo' with aggregate 'false'` + ); + expect(console.debug).toHaveBeenNthCalledWith( + 2, + `Registering onSubscribe route handler for path '/bar' with aggregate 'false'` + ); + expect(console.debug).toHaveBeenNthCalledWith( + 3, + `Registering onPublish route handler for path '/baz/*' with aggregate 'true'` + ); + // verify that class scope is preserved after decorating + expect(res1).toBe('value foo'); + expect(res2).toBe('value bar'); + expect(res3).toBe('value baz'); + }); + + it('uses a default logger with only warnings if none is provided', () => { + // Prepare + const router = new Router(); + + // Act + router.onPublish('/foo', vi.fn()); + + // Assess + expect(console.debug).not.toHaveBeenCalled(); + }); + + it('emits debug messages when ALC_LOG_LEVEL is set to DEBUG', () => { + // Prepare + process.env.AWS_LAMBDA_LOG_LEVEL = 'DEBUG'; + const router = new Router(); + + // Act + router.onPublish('/foo', vi.fn()); + + // Assess + expect(console.debug).toHaveBeenCalled(); + process.env.AWS_LAMBDA_LOG_LEVEL = undefined; + }); +}); diff --git a/packages/event-handler/tests/unit/index.test.ts b/packages/event-handler/tests/unit/index.test.ts deleted file mode 100644 index cc067e7c70..0000000000 --- a/packages/event-handler/tests/unit/index.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { foo } from '../../src/index.js'; - -describe('Event Handler', () => { - const ENVIRONMENT_VARIABLES = process.env; - - beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - process.env = { ...ENVIRONMENT_VARIABLES }; - }); - - afterAll(() => { - process.env = ENVIRONMENT_VARIABLES; - }); - - it('should return true', () => { - // Act - const result = foo(); - - // Assess - expect(result).toBe(true); - }); -}); diff --git a/packages/event-handler/typedoc.json b/packages/event-handler/typedoc.json new file mode 100644 index 0000000000..2ce51a11ff --- /dev/null +++ b/packages/event-handler/typedoc.json @@ -0,0 +1,10 @@ +{ + "extends": [ + "../../typedoc.base.json" + ], + "entryPoints": [ + "./src/appsync-events/index.ts", + "./src/types/index.ts", + ], + "readme": "README.md" +} \ No newline at end of file diff --git a/typedoc.json b/typedoc.json index 7f053ad259..0d76f2b0c4 100644 --- a/typedoc.json +++ b/typedoc.json @@ -12,7 +12,6 @@ "**/*.json", "layers", "examples/**", - "packages/event-handler", "packages/testing" ], "plugin": ["typedoc-plugin-zod", "typedoc-plugin-missing-exports"],