diff --git a/docs/core/event_handler/appsync_events.md b/docs/core/event_handler/appsync_events.md new file mode 100644 index 00000000..75ac6410 --- /dev/null +++ b/docs/core/event_handler/appsync_events.md @@ -0,0 +1,714 @@ +--- +title: AppSync Events +description: Event Handler - AppSync Events +--- + +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 or sequentially +* Control over event 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 Lambda function. + +=== "Getting started with AppSync Events" + + ```yaml + 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: 365 + + WebsocketAPINamespace: + Type: AWS::AppSync::ChannelNamespace + Properties: + ApiId: !GetAtt WebsocketAPI.ApiId + Name: powertools + ``` + +### 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. + +=== "AppSync payload request" + + ```json + { + "identity":"None", + "result":"None", + "request":{ + "headers": { + "x-forwarded-for": "1.1.1.1, 2.2.2.2", + "cloudfront-viewer-country": "US", + "cloudfront-is-tablet-viewer": "false", + "via": "2.0 xxxxxxxxxxxxxxxx.cloudfront.net (CloudFront)", + "cloudfront-forwarded-proto": "https", + "origin": "https://us-west-1.console.aws.amazon.com", + "content-length": "217", + "accept-language": "en-US,en;q=0.9", + "host": "xxxxxxxxxxxxxxxx.appsync-api.us-west-1.amazonaws.com", + "x-forwarded-proto": "https", + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36", + "accept": "*/*", + "cloudfront-is-mobile-viewer": "false", + "cloudfront-is-smarttv-viewer": "false", + "accept-encoding": "gzip, deflate, br", + "referer": "https://us-west-1.console.aws.amazon.com/appsync/home?region=us-west-1", + "content-type": "application/json", + "sec-fetch-mode": "cors", + "x-amz-cf-id": "3aykhqlUwQeANU-HGY7E_guV5EkNeMMtwyOgiA==", + "x-amzn-trace-id": "Root=1-5f512f51-fac632066c5e848ae714", + "authorization": "eyJraWQiOiJScWFCSlJqYVJlM0hrSnBTUFpIcVRXazNOW...", + "sec-fetch-dest": "empty", + "x-amz-user-agent": "AWS-Console-AppSync/", + "cloudfront-is-desktop-viewer": "true", + "sec-fetch-site": "cross-site", + "x-forwarded-port": "443" + }, + "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" + } + ] + } + + ``` + +=== "AppSync payload response" + + ```json + { + "events":[ + { + "payload":{ + "data":"data_1" + }, + "id":"1" + }, + { + "payload":{ + "data":"data_2" + }, + "id":"2" + } + ] + } + + ``` + +=== "AppSync payload response with error" + + ```json + { + "events":[ + { + "error": "Error message", + "id":"1" + }, + { + "payload":{ + "data":"data_2" + }, + "id":"2" + } + ] + } + ``` + +#### 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**: Raise the **UnauthorizedException** exception to reject a subscribe or publish request with HTTP 403. + +### Resolver + +???+ important + When you return `Resolve` or `ResolveAsync` from your handler it will automatically parse 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()`, `OnPublishAggregate()`, and `OnSubscribe()` methods and their `Async` versions `OnPublishAsync()`, `OnPublishAggregateAsync()`, and `OnSubscribeAsync()`. + +=== "Publish events - Class library handler" + + ```chsarp hl_lines="1 5 9-15 20" + using AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + + public class Function + { + AppSyncEventsResolver _app; + + public Function() + { + _app = new AppSyncEventsResolver(); + _app.OnPublishAsync("/default/channel", async (payload) => + { + // Handle events or + // return unchanged payload + return payload; + }); + } + + public async Task FunctionHandler(AppSyncEventsRequest input, ILambdaContext context) + { + return await _app.ResolveAsync(input, context); + } + } + ``` +=== "Publish events - Executable assembly handlers" + + ```chsarp hl_lines="1 3 5-10 14" + using AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + + var app = new AppSyncEventsResolver(); + + app.OnPublishAsync("/default/channel", async (payload) => + { + // Handle events or + // return unchanged payload + return payload; + } + + async Task Handler(AppSyncEventsRequest appSyncEvent, ILambdaContext context) + { + return await app.ResolveAsync(appSyncEvent, context); + } + + await LambdaBootstrapBuilder.Create((Func>)Handler, + new DefaultLambdaJsonSerializer()) + .Build() + .RunAsync(); + + ``` + +=== "Subscribe to events" + + ```csharp + app.OnSubscribe("/default/*", (payload) => + { + // Handle subscribe events + // return true to allow subscription + // return false or throw to reject subscription + return true; + }); + ``` + +## 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 handlers, the most specific pattern takes precedence. + +=== "Wildcard patterns" + + ```csharp + app.OnPublish("/default/channel1", (payload) => + { + // This handler will be called for events on /default/channel1 + return payload; + }); + + app.OnPublish("/default/*", (payload) => + { + // This handler will be called for all channels in the default namespace + // EXCEPT for /default/channel1 which has a more specific handler + return payload; + }); + + app.OnPublish("/*", (payload) => + { + # This handler will be called for all channels in all namespaces + # EXCEPT for those that have more specific handlers + return payload; + }); + ``` + +???+ 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 routes will always take precedence over less specific ones. For example, `/default/channel1` will take precedence over `/default/*`, which will take precedence over `/*`. + +### Aggregated processing + +???+ note "Aggregate Processing" + `OnPublishAggregate()` and `OnPublishAggregateAsync()`, 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). + +In some scenarios, you might want to process all events for a channel as a batch rather than individually. This is useful when you need to: + +* 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 + +=== "Aggregated processing" + + ```csharp + app.OnPublishAggregate("/default/channel", (payload) => + { + var evt = new List(); + + foreach (var item in payload.Events) + { + if (item.Payload["eventType"].ToString() == "data_2") + { + pd.Payload["message"] = "Hello from /default/channel2 with data_2"; + pd.Payload["data"] = new Dictionary + { + { "key", "value" } + }; + } + + evt.Add(pd); + } + + return new AppSyncEventsResponse + { + Events = evt + }; + }); + ``` + +### Handling errors + +You can filter or reject events by raising 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 with `OnPublish()` and `OnPublishAsync()`, you can raise an exception to fail a specific item. When an exception is raised, the Event Handler will catch it and include the exception name and message in the response. + +=== "Error handling individual items" + + ```csharp + app.OnPublish("/default/channel", (payload) => + { + throw new Exception("My custom exception"); + }); + ``` + +=== "Error handling individual items Async" + + ```csharp + app.OnPublishAsync("/default/channel", async (payload) => + { + throw new Exception("My custom exception"); + }); + ``` + +=== "Error handling individual items response" + + ```json hl_lines="4" + { + "events":[ + { + "error": "My custom exception", + "id":"1" + }, + { + "payload":{ + "data":"data_2" + }, + "id":"2" + } + ] + } + ``` + +#### Handling errors with batch of items + +When processing batch of items with `OnPublishAggregate()` and `OnPublishAggregateAsync()`, you must format the payload according the expected response. + +=== "Error handling batch items" + + ```csharp + app.OnPublishAggregate("/default/channel", (payload) => + { + throw new Exception("My custom exception"); + }); + ``` + +=== "Error handling batch items Async" + + ```csharp + app.OnPublishAggregateAsync("/default/channel", async (payload) => + { + throw new Exception("My custom exception"); + }); + ``` + +=== "Error handling batch items response" + + ```json + { + "error": "My custom exception" + } + ``` + +#### Authorization control + +??? warning "Raising `UnauthorizedException` will cause the Lambda invocation to fail." + +You can also reject the entire payload by raising an `UnauthorizedException`. This prevents Powertools for AWS from processing any messages and causes the Lambda invocation to fail, returning an error to AppSync. + +- **When working with publish events** Powertools for AWS will stop processing messages and subscribers will not receive any message. +- **When working with subscribe events** the subscription won't be established. + +=== "Rejecting the entire request" + + ```csharp + app.OnPublish("/default/channel", (payload) => + { + throw new UnauthorizedException("My custom exception"); + }); + ``` + +### Accessing Lambda context and event + +You can access to the original Lambda event or context for additional information. These are accessible via the app instance: + +=== "Accessing Lambda context" + + ```csharp hl_lines="1 3" + app.OnPublish("/default/channel", (payload, ctx) => + { + payload["functionName"] = ctx.FunctionName; + return payload; + }); + ``` + +## Event Handler workflow + +#### 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 +``` +
+ +#### Authorization fails for 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 +``` +
+ +#### Authorization fails for 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 mocked or actual AppSync Events Lambda event. + +### Testing publish events + +=== "Test Publish events" + + ```csharp + [Fact] + public void Should_Return_Unchanged_Payload() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/default/channel", payload => + { + // Handle channel events + return payload; + }); + + // Act + var result = app.Resolve(_appSyncEvent, lambdaContext); + + // Assert + Assert.Equal("123", result.Events[0].Id); + Assert.Equal("test data", result.Events[0].Payload?["data"].ToString()); + } + ``` + +=== "Publish event json" + + ```json + { + "identity":"None", + "result":"None", + "request":{ + "headers": { + "x-forwarded-for": "1.1.1.1, 2.2.2.2", + "cloudfront-viewer-country": "US", + "cloudfront-is-tablet-viewer": "false", + "via": "2.0 xxxxxxxxxxxxxxxx.cloudfront.net (CloudFront)", + "cloudfront-forwarded-proto": "https", + "origin": "https://us-west-1.console.aws.amazon.com", + "content-length": "217", + "accept-language": "en-US,en;q=0.9", + "host": "xxxxxxxxxxxxxxxx.appsync-api.us-west-1.amazonaws.com", + "x-forwarded-proto": "https", + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36", + "accept": "*/*", + "cloudfront-is-mobile-viewer": "false", + "cloudfront-is-smarttv-viewer": "false", + "accept-encoding": "gzip, deflate, br", + "referer": "https://us-west-1.console.aws.amazon.com/appsync/home?region=us-west-1", + "content-type": "application/json", + "sec-fetch-mode": "cors", + "x-amz-cf-id": "3aykhqlUwQeANU-HGY7E_guV5EkNeMMtwyOgiA==", + "x-amzn-trace-id": "Root=1-5f512f51-fac632066c5e848ae714", + "authorization": "eyJraWQiOiJScWFCSlJqYVJlM0hrSnBTUFpIcVRXazNOW...", + "sec-fetch-dest": "empty", + "x-amz-user-agent": "AWS-Console-AppSync/", + "cloudfront-is-desktop-viewer": "true", + "sec-fetch-site": "cross-site", + "x-forwarded-port": "443" + }, + "domainName":"None" + }, + "info":{ + "channel":{ + "path":"/default/channel", + "segments":[ + "default", + "channel" + ] + }, + "channelNamespace":{ + "name":"default" + }, + "operation":"PUBLISH" + }, + "error":"None", + "prev":"None", + "stash":{ + + }, + "outErrors":[ + + ], + "events":[ + { + "payload":{ + "data": "test data" + }, + "id":"123" + } + ] + } + ``` + +### Testing subscribe events + +=== "Test Subscribe with code payload mock" + + ```csharp + [Fact] + public async Task Should_Authorize_Subscription() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnSubscribeAsync("/default/*", async (info) => true); + + var subscribeEvent = new AppSyncEventsRequest + { + Info = new Information + { + Channel = new Channel + { + Path = "/default/channel", + Segments = ["default", "channel"] + }, + Operation = AppSyncEventsOperation.Subscribe, + ChannelNamespace = new ChannelNamespace { Name = "default" } + } + }; + // Act + var result = await app.ResolveAsync(subscribeEvent, lambdaContext); + + // Assert + Assert.Null(result); + } + ``` \ No newline at end of file diff --git a/libraries/AWS.Lambda.Powertools.sln b/libraries/AWS.Lambda.Powertools.sln index 07122c3a..5d7cd4f9 100644 --- a/libraries/AWS.Lambda.Powertools.sln +++ b/libraries/AWS.Lambda.Powertools.sln @@ -105,6 +105,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Metrics", "Metrics", "{A566 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AOT-Function-ILogger", "tests\e2e\functions\core\logging\AOT-Function-ILogger\src\AOT-Function-ILogger\AOT-Function-ILogger.csproj", "{7FC6DD65-0352-4139-8D08-B25C0A0403E3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AWS.Lambda.Powertools.EventHandler.Tests", "tests\AWS.Lambda.Powertools.EventHandler.Tests\AWS.Lambda.Powertools.EventHandler.Tests.csproj", "{61374D8E-F77C-4A31-AE07-35DAF1847369}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AWS.Lambda.Powertools.EventHandler", "src\AWS.Lambda.Powertools.EventHandler\AWS.Lambda.Powertools.EventHandler.csproj", "{F4B8D5AF-D3CA-4910-A14D-E5BAEF0FD1DE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -562,6 +566,30 @@ Global {7FC6DD65-0352-4139-8D08-B25C0A0403E3}.Release|x64.Build.0 = Release|Any CPU {7FC6DD65-0352-4139-8D08-B25C0A0403E3}.Release|x86.ActiveCfg = Release|Any CPU {7FC6DD65-0352-4139-8D08-B25C0A0403E3}.Release|x86.Build.0 = Release|Any CPU + {61374D8E-F77C-4A31-AE07-35DAF1847369}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61374D8E-F77C-4A31-AE07-35DAF1847369}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61374D8E-F77C-4A31-AE07-35DAF1847369}.Debug|x64.ActiveCfg = Debug|Any CPU + {61374D8E-F77C-4A31-AE07-35DAF1847369}.Debug|x64.Build.0 = Debug|Any CPU + {61374D8E-F77C-4A31-AE07-35DAF1847369}.Debug|x86.ActiveCfg = Debug|Any CPU + {61374D8E-F77C-4A31-AE07-35DAF1847369}.Debug|x86.Build.0 = Debug|Any CPU + {61374D8E-F77C-4A31-AE07-35DAF1847369}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61374D8E-F77C-4A31-AE07-35DAF1847369}.Release|Any CPU.Build.0 = Release|Any CPU + {61374D8E-F77C-4A31-AE07-35DAF1847369}.Release|x64.ActiveCfg = Release|Any CPU + {61374D8E-F77C-4A31-AE07-35DAF1847369}.Release|x64.Build.0 = Release|Any CPU + {61374D8E-F77C-4A31-AE07-35DAF1847369}.Release|x86.ActiveCfg = Release|Any CPU + {61374D8E-F77C-4A31-AE07-35DAF1847369}.Release|x86.Build.0 = Release|Any CPU + {F4B8D5AF-D3CA-4910-A14D-E5BAEF0FD1DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F4B8D5AF-D3CA-4910-A14D-E5BAEF0FD1DE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F4B8D5AF-D3CA-4910-A14D-E5BAEF0FD1DE}.Debug|x64.ActiveCfg = Debug|Any CPU + {F4B8D5AF-D3CA-4910-A14D-E5BAEF0FD1DE}.Debug|x64.Build.0 = Debug|Any CPU + {F4B8D5AF-D3CA-4910-A14D-E5BAEF0FD1DE}.Debug|x86.ActiveCfg = Debug|Any CPU + {F4B8D5AF-D3CA-4910-A14D-E5BAEF0FD1DE}.Debug|x86.Build.0 = Debug|Any CPU + {F4B8D5AF-D3CA-4910-A14D-E5BAEF0FD1DE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F4B8D5AF-D3CA-4910-A14D-E5BAEF0FD1DE}.Release|Any CPU.Build.0 = Release|Any CPU + {F4B8D5AF-D3CA-4910-A14D-E5BAEF0FD1DE}.Release|x64.ActiveCfg = Release|Any CPU + {F4B8D5AF-D3CA-4910-A14D-E5BAEF0FD1DE}.Release|x64.Build.0 = Release|Any CPU + {F4B8D5AF-D3CA-4910-A14D-E5BAEF0FD1DE}.Release|x86.ActiveCfg = Release|Any CPU + {F4B8D5AF-D3CA-4910-A14D-E5BAEF0FD1DE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution @@ -611,5 +639,7 @@ Global {F8F80477-1EAD-4C5C-A329-CBC0A60C7CAB} = {A566F2D7-F8FE-466A-8306-85F266B7E656} {A422C742-2CF9-409D-BDAE-15825AB62113} = {A566F2D7-F8FE-466A-8306-85F266B7E656} {7FC6DD65-0352-4139-8D08-B25C0A0403E3} = {4EAB66F9-C9CB-4E8A-BEE6-A14CD7FDE02F} + {61374D8E-F77C-4A31-AE07-35DAF1847369} = {1CFF5568-8486-475F-81F6-06105C437528} + {F4B8D5AF-D3CA-4910-A14D-E5BAEF0FD1DE} = {73C9B1E5-3893-47E8-B373-17E5F5D7E6F5} EndGlobalSection EndGlobal diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AWS.Lambda.Powertools.EventHandler.csproj b/libraries/src/AWS.Lambda.Powertools.EventHandler/AWS.Lambda.Powertools.EventHandler.csproj new file mode 100644 index 00000000..7c281eff --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AWS.Lambda.Powertools.EventHandler.csproj @@ -0,0 +1,19 @@ + + + + + AWS.Lambda.Powertools.EventHandler + Powertools for AWS Lambda (.NET) - Event Handler package. + AWS.Lambda.Powertools.EventHandler + AWS.Lambda.Powertools.EventHandler + net8.0 + false + enable + enable + + + + + + + diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncCognitoIdentity.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncCognitoIdentity.cs new file mode 100644 index 00000000..e59f7949 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncCognitoIdentity.cs @@ -0,0 +1,42 @@ +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Represents Amazon Cognito User Pools authorization identity for AppSync +/// +public class AppSyncCognitoIdentity +{ + /// + /// The source IP address of the caller received by AWS AppSync + /// + public List? SourceIp { get; set; } + + /// + /// The username of the authenticated user + /// + public string? Username { get; set; } + + /// + /// The UUID of the authenticated user + /// + public string? Sub { get; set; } + + /// + /// The claims that the user has + /// + public Dictionary? Claims { get; set; } + + /// + /// The default authorization strategy for this caller (ALLOW or DENY) + /// + public string? DefaultAuthStrategy { get; set; } + + /// + /// List of OIDC groups + /// + public List? Groups { get; set; } + + /// + /// The token issuer + /// + public string? Issuer { get; set; } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEvent.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEvent.cs new file mode 100644 index 00000000..9ed03423 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEvent.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Represents an event from AWS AppSync. +/// +public class AppSyncEvent +{ + /// + /// Payload data when operation succeeds + /// + [JsonPropertyName("payload")] + public Dictionary? Payload { get; set; } + + /// + /// Error message when operation fails + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("error")] + public string? Error { get; set; } + + /// + /// Unique identifier for the event + /// This Id is provided by AppSync and needs to be preserved. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsOperation.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsOperation.cs new file mode 100644 index 00000000..ffb970dd --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsOperation.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Represents the operation type for AppSync events. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AppSyncEventsOperation +{ + /// + /// Represents a subscription operation. + /// + Subscribe, + + /// + /// Represents a publish operation. + /// + Publish +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsRequest.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsRequest.cs new file mode 100644 index 00000000..46097d44 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsRequest.cs @@ -0,0 +1,74 @@ +using System.Text.Json.Serialization; + +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Represents the event payload received from AWS AppSync. +/// +public class AppSyncEventsRequest +{ + /// + /// An object that contains information about the caller. + /// Returns null for API_KEY authorization. + /// Returns AppSyncIamIdentity for AWS_IAM authorization. + /// Returns AppSyncCognitoIdentity for AMAZON_COGNITO_USER_POOLS authorization. + /// For AWS_LAMBDA authorization, returns the object returned by your Lambda authorizer function. + /// + /// + /// The Identity object type depends on the authorization mode: + /// - For API_KEY: null + /// - For AWS_IAM: + /// - For AMAZON_COGNITO_USER_POOLS: + /// - For AWS_LAMBDA: + /// - For OPENID_CONNECT: + /// + public object? Identity { get; set; } + + /// + /// Gets or sets information about the data source that originated the event. + /// + [JsonPropertyName("source")] + public object? Source { get; set; } + + /// + /// Gets or sets information about the HTTP request that triggered the event. + /// + [JsonPropertyName("request")] + public RequestContext? Request { get; set; } + + /// + /// Gets or sets information about the previous state of the data before the operation was executed. + /// + [JsonPropertyName("prev")] + public object? Prev { get; set; } + + /// + /// Gets or sets information about the GraphQL operation being executed. + /// + [JsonPropertyName("info")] + public Information? Info { get; set; } + + /// + /// Gets or sets additional information that can be passed between Lambda functions during an AppSync pipeline. + /// + [JsonPropertyName("stash")] + public Dictionary? Stash { get; set; } + + /// + /// The error message when the operation fails. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("error")] + public string? Error { get; set; } + + /// + /// The list of error message when the operation fails. + /// + public object[]? OutErrors { get; set; } + + /// + /// The list of events sent. + /// + [JsonPropertyName("events")] + public AppSyncEvent[]? Events { get; set; } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs new file mode 100644 index 00000000..5213f66f --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs @@ -0,0 +1,550 @@ +using Amazon.Lambda.Core; +using AWS.Lambda.Powertools.EventHandler.Internal; + +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Resolver for AWS AppSync Events APIs. +/// Handles onPublish and onSubscribe events from AppSync Events APIs, +/// routing them to appropriate handlers based on path. +/// +public class AppSyncEventsResolver +{ + private readonly RouteHandlerRegistry _publishRoutes; + private readonly RouteHandlerRegistry _subscribeRoutes; + + /// + /// Initializes a new instance of the class. + /// + public AppSyncEventsResolver() + { + _publishRoutes = new RouteHandlerRegistry(); + _subscribeRoutes = new RouteHandlerRegistry(); + } + + #region OnPublish Methods + + + /// + /// Registers a sync handler for publish events on a specific channel path. + /// + /// The channel path to handle + /// Sync handler without context + public AppSyncEventsResolver OnPublish(string path, Func, object> handler) + { + RegisterPublishHandler(path, handler, false); + return this; + } + + /// + /// Registers a sync handler with Lambda context for publish events on a specific channel path. + /// + /// The channel path to handle + /// Sync handler with context + public AppSyncEventsResolver OnPublish(string path, Func, ILambdaContext, object> handler) + { + RegisterPublishHandler(path, handler, false); + return this; + } + + #endregion + + #region OnPublishAsync Methods + + /// + /// Explicitly registers an async handler for publish events on a specific channel path. + /// Use this method when you want to clearly indicate that your handler is asynchronous. + /// + /// The channel path to handle + /// Async handler without context + public AppSyncEventsResolver OnPublishAsync(string path, Func, Task> handler) + { + RegisterPublishHandler(path, handler, false); + return this; + } + + /// + /// Explicitly registers an async handler with Lambda context for publish events on a specific channel path. + /// Use this method when you want to clearly indicate that your handler is asynchronous. + /// + /// The channel path to handle + /// Async handler with context + public AppSyncEventsResolver OnPublishAsync(string path, Func, ILambdaContext, Task> handler) + { + RegisterPublishHandler(path, handler, false); + return this; + } + + #endregion + + #region OnPublishAggregate Methods + + /// + /// Registers a sync aggregate handler for publish events on a specific channel path. + /// + /// The channel path to handle + /// Sync aggregate handler without context + public AppSyncEventsResolver OnPublishAggregate(string path, Func handler) + { + RegisterAggregateHandler(path, handler); + return this; + } + + /// + /// Registers a sync aggregate handler with Lambda context for publish events on a specific channel path. + /// + /// The channel path to handle + /// Sync aggregate handler with context + public AppSyncEventsResolver OnPublishAggregate(string path, Func handler) + { + RegisterAggregateHandler(path, handler); + return this; + } + + #endregion + + #region OnPublishAggregateAsync Methods + + /// + /// Explicitly registers an async aggregate handler for publish events on a specific channel path. + /// Use this method when you want to clearly indicate that your handler is asynchronous. + /// + /// The channel path to handle + /// Async aggregate handler without context + public AppSyncEventsResolver OnPublishAggregateAsync(string path, Func> handler) + { + RegisterAggregateHandler(path, handler); + return this; + } + + /// + /// Explicitly registers an async aggregate handler with Lambda context for publish events on a specific channel path. + /// Use this method when you want to clearly indicate that your handler is asynchronous. + /// + /// The channel path to handle + /// Async aggregate handler with context + public AppSyncEventsResolver OnPublishAggregateAsync(string path, Func> handler) + { + RegisterAggregateHandler(path, handler); + return this; + } + + #endregion + + #region OnSubscribe Methods + + /// + /// Registers a sync handler for subscription events on a specific channel path. + /// + /// The channel path to handle + /// Sync subscription handler without context + public AppSyncEventsResolver OnSubscribe(string path, Func handler) + { + RegisterSubscribeHandler(path, handler); + return this; + } + + /// + /// Registers a sync handler with Lambda context for subscription events on a specific channel path. + /// + /// The channel path to handle + /// Sync subscription handler with context + public AppSyncEventsResolver OnSubscribe(string path, Func handler) + { + RegisterSubscribeHandler(path, handler); + return this; + } + + #endregion + + #region OnSubscribeAsync Methods + + /// + /// Explicitly registers an async handler for subscription events on a specific channel path. + /// Use this method when you want to clearly indicate that your handler is asynchronous. + /// + /// The channel path to handle + /// Async subscription handler without context + public AppSyncEventsResolver OnSubscribeAsync(string path, Func> handler) + { + RegisterSubscribeHandler(path, handler); + return this; + } + + /// + /// Explicitly registers an async handler with Lambda context for subscription events on a specific channel path. + /// Use this method when you want to clearly indicate that your handler is asynchronous. + /// + /// The channel path to handle + /// Async subscription handler with context + public AppSyncEventsResolver OnSubscribeAsync(string path, Func> handler) + { + RegisterSubscribeHandler(path, handler); + return this; + } + + #endregion + + #region Handler Registration Methods + + private void RegisterPublishHandler(string path, Func, Task> handler, bool aggregate) + { + _publishRoutes.Register(new RouteHandlerOptions + { + Path = path, + Handler = async (evt, _) => + { + var payload = evt.Events?.FirstOrDefault()?.Payload; + return await handler(payload ?? new Dictionary()); + }, + Aggregate = aggregate + }); + } + + private void RegisterPublishHandler(string path, Func, ILambdaContext, Task> handler, bool aggregate) + { + _publishRoutes.Register(new RouteHandlerOptions + { + Path = path, + Handler = async (evt, ctx) => + { + var payload = evt.Events?.FirstOrDefault()?.Payload; + return await handler(payload ?? new Dictionary(), ctx); + }, + Aggregate = aggregate + }); + } + + private void RegisterPublishHandler(string path, Func, object> handler, bool aggregate) + { + _publishRoutes.Register(new RouteHandlerOptions + { + Path = path, + Handler = (evt, _) => + { + var payload = evt.Events?.FirstOrDefault()?.Payload; + return Task.FromResult(handler(payload ?? new Dictionary())); + }, + Aggregate = aggregate + }); + } + + private void RegisterPublishHandler(string path, Func, ILambdaContext, object> handler, bool aggregate) + { + _publishRoutes.Register(new RouteHandlerOptions + { + Path = path, + Handler = (evt, ctx) => + { + var payload = evt.Events?.FirstOrDefault()?.Payload; + return Task.FromResult(handler(payload ?? new Dictionary(), ctx)); + }, + Aggregate = aggregate + }); + } + + private void RegisterAggregateHandler(string path, Func> handler) + { + _publishRoutes.Register(new RouteHandlerOptions + { + Path = path, + Handler = async (evt, _) => await handler(evt), + Aggregate = true + }); + } + + private void RegisterAggregateHandler(string path, Func> handler) + { + _publishRoutes.Register(new RouteHandlerOptions + { + Path = path, + Handler = async (evt, ctx) => await handler(evt, ctx), + Aggregate = true + }); + } + + private void RegisterAggregateHandler(string path, Func handler) + { + _publishRoutes.Register(new RouteHandlerOptions + { + Path = path, + Handler = (evt, _) => Task.FromResult((object)handler(evt)), + Aggregate = true + }); + } + + private void RegisterAggregateHandler(string path, Func handler) + { + _publishRoutes.Register(new RouteHandlerOptions + { + Path = path, + Handler = (evt, ctx) => Task.FromResult((object)handler(evt, ctx)), + Aggregate = true + }); + } + + private void RegisterSubscribeHandler(string path, Func> handler) + { + _subscribeRoutes.Register(new RouteHandlerOptions + { + Path = path, + Handler = async (evt, _) => await handler(evt) + }); + } + + private void RegisterSubscribeHandler(string path, Func> handler) + { + _subscribeRoutes.Register(new RouteHandlerOptions + { + Path = path, + Handler = async (evt, ctx) => await handler(evt, ctx) + }); + } + + private void RegisterSubscribeHandler(string path, Func handler) + { + _subscribeRoutes.Register(new RouteHandlerOptions + { + Path = path, + Handler = (evt, _) => Task.FromResult(handler(evt)) + }); + } + + private void RegisterSubscribeHandler(string path, Func handler) + { + _subscribeRoutes.Register(new RouteHandlerOptions + { + Path = path, + Handler = (evt, ctx) => Task.FromResult(handler(evt, ctx)) + }); + } + + #endregion + + /// + /// Resolves and processes an AppSync event through the registered handlers. + /// + /// The AppSync event to process + /// Lambda execution context + /// Response containing processed events or error information + public AppSyncEventsResponse Resolve(AppSyncEventsRequest appsyncEvent, ILambdaContext context) + { + return ResolveAsync(appsyncEvent, context).GetAwaiter().GetResult(); + } + + /// + /// Resolves and processes an AppSync event through the registered handlers. + /// + /// The AppSync event to process + /// Lambda execution context + /// Response containing processed events or error information + public async Task ResolveAsync(AppSyncEventsRequest appsyncEvent, ILambdaContext context) + { + if (IsPublishEvent(appsyncEvent)) + { + return await HandlePublishEvent(appsyncEvent, context); + } + + if (IsSubscribeEvent(appsyncEvent)) + { + return (await HandleSubscribeEvent(appsyncEvent, context))!; + } + + throw new InvalidOperationException("Unknown event type"); + } + + private async Task HandlePublishEvent(AppSyncEventsRequest appsyncEvent, + ILambdaContext context) + { + var channelPath = appsyncEvent.Info?.Channel?.Path; + var handlerOptions = _publishRoutes.ResolveFirst(channelPath); + + context.Logger.LogInformation($"Resolving publish event for path: {channelPath}"); + + if (handlerOptions == null) + { + // Return unchanged events if no handler found + var events = appsyncEvent.Events? + .Select(e => new AppSyncEvent + { + Id = e.Id, + Payload = e.Payload + }) + .ToList(); + return new AppSyncEventsResponse { Events = events }; + } + + var results = new List(); + + if (handlerOptions.Aggregate) + { + try + { + // Process entire event in one call + var handlerResult = await handlerOptions.Handler(appsyncEvent, context); + if (handlerResult is AppSyncEventsResponse { Events: not null } result) + { + return result; + } + + // Handle unexpected return type + return new AppSyncEventsResponse + { + Error = "Handler returned an invalid response type" + }; + } + catch (UnauthorizedException) + { + throw; + } + catch (Exception ex) + { + return new AppSyncEventsResponse + { + Error = $"{ex.GetType().Name} - {ex.Message}" + }; + } + } + else + { + // Process each event individually + if (appsyncEvent.Events == null) return new AppSyncEventsResponse { Events = results }; + foreach (var eventItem in appsyncEvent.Events) + { + try + { + var result = await handlerOptions.Handler( + new AppSyncEventsRequest + { + Info = appsyncEvent.Info, + Events = [eventItem] + }, context); + + var payload = ConvertToPayload(result, out var error); + if (error != null) + { + results.Add(new AppSyncEvent + { + Id = eventItem.Id, + Error = error + }); + } + else + { + results.Add(new AppSyncEvent + { + Id = eventItem.Id, + Payload = payload + }); + } + } + catch (UnauthorizedException) + { + throw; + } + catch (Exception ex) + { + results.Add(FormatErrorResponse(ex, eventItem.Id!)); + } + } + } + + return new AppSyncEventsResponse { Events = results }; + } + + /// + /// Handles subscription events. + /// Returns null on success, error response on failure. + /// + private async Task HandleSubscribeEvent(AppSyncEventsRequest appsyncEvent, + ILambdaContext context) + { + var channelPath = appsyncEvent.Info?.Channel?.Path; + var channelBase = $"/{appsyncEvent.Info?.Channel?.Segments?[0]}"; + + // Find matching subscribe handler + var subscribeHandler = _subscribeRoutes.ResolveFirst(channelPath); + if (subscribeHandler == null) + { + return null; + } + + // Check if there's ANY publish handler for the base channel namespace + bool hasAnyPublishHandler = _publishRoutes.GetAllHandlers() + .Any(h => h.Path.StartsWith(channelBase)); + + if (!hasAnyPublishHandler) + { + return null; + } + + try + { + var result = await subscribeHandler.Handler(appsyncEvent, context); + return !result ? new AppSyncEventsResponse { Error = "Subscription failed" } : null; + } + catch (UnauthorizedException) + { + throw; + } + catch (Exception ex) + { + context.Logger.LogLine($"Error in subscribe handler: {ex.Message}"); + return new AppSyncEventsResponse { Error = ex.Message }; + } + } + + private Dictionary? ConvertToPayload(object result, out string? error) + { + error = null; + + // Check if this is an error result from ProcessSingleEvent + if (result is Dictionary dict && dict.ContainsKey("error")) + { + error = dict["error"].ToString(); + return null; // No payload when there's an error + } + + // Regular payload handling + if (result is Dictionary payload) + { + return payload; + } + + return new Dictionary { ["data"] = result }; + } + + private AppSyncEvent FormatErrorResponse(Exception ex, string id) + { + return new AppSyncEvent + { + Id = id, + Error = $"{ex.GetType().Name} - {ex.Message}" + }; + } + + private bool IsPublishEvent(AppSyncEventsRequest appsyncEvent) + { + return appsyncEvent.Info?.Operation == AppSyncEventsOperation.Publish; + } + + private bool IsSubscribeEvent(AppSyncEventsRequest appsyncEvent) + { + return appsyncEvent.Info?.Operation == AppSyncEventsOperation.Subscribe; + } +} + +/// +/// Exception thrown when subscription validation fails. +/// This exception causes the Lambda invocation to fail, returning an error to AppSync. +/// +public class UnauthorizedException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// The error message + public UnauthorizedException(string message) : base(message) + { + } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResponse.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResponse.cs new file mode 100644 index 00000000..7069cba5 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResponse.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Represents the response for AppSync events. +/// +public class AppSyncEventsResponse +{ + /// + /// Collection of event results + /// + [JsonPropertyName("events")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public List? Events { get; set; } + + /// + /// When operation fails, this will contain the error message + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("error")] + public string? Error { get; set; } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncIamIdentity.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncIamIdentity.cs new file mode 100644 index 00000000..f2cf0a17 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncIamIdentity.cs @@ -0,0 +1,47 @@ +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Represents AWS IAM authorization identity for AppSync +/// +public class AppSyncIamIdentity +{ + /// + /// The source IP address of the caller received by AWS AppSync + /// + public List? SourceIp { get; set; } + + /// + /// The username of the authenticated user (IAM user principal) + /// + public string? Username { get; set; } + + /// + /// The AWS account ID of the caller + /// + public string? AccountId { get; set; } + + /// + /// The Amazon Cognito identity pool ID associated with the caller + /// + public string? CognitoIdentityPoolId { get; set; } + + /// + /// The Amazon Cognito identity ID of the caller + /// + public string? CognitoIdentityId { get; set; } + + /// + /// The ARN of the IAM user + /// + public string? UserArn { get; set; } + + /// + /// Either authenticated or unauthenticated based on the identity type + /// + public string? CognitoIdentityAuthType { get; set; } + + /// + /// A comma separated list of external identity provider information used in obtaining the credentials used to sign the request + /// + public string? CognitoIdentityAuthProvider { get; set; } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncLambdaIdentity.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncLambdaIdentity.cs new file mode 100644 index 00000000..996aa9a0 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncLambdaIdentity.cs @@ -0,0 +1,13 @@ +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Represents AWS Lambda authorization identity for AppSync +/// +public class AppSyncLambdaIdentity +{ + /// + /// Optional context information that will be passed to subsequent resolvers + /// Can contain user information, claims, or any other contextual data + /// + public Dictionary? ResolverContext { get; set; } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncOidcIdentity.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncOidcIdentity.cs new file mode 100644 index 00000000..8d06db2e --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncOidcIdentity.cs @@ -0,0 +1,22 @@ +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Represents OpenID Connect authorization identity for AppSync +/// +public class AppSyncOidcIdentity +{ + /// + /// Claims from the OIDC token as key-value pairs + /// + public Dictionary? Claims { get; set; } + + /// + /// The issuer of the OIDC token + /// + public string? Issuer { get; set; } + + /// + /// The UUID of the authenticated user + /// + public string? Sub { get; set; } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncRequestContext.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncRequestContext.cs new file mode 100644 index 00000000..3fe5681d --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncRequestContext.cs @@ -0,0 +1,40 @@ +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Contains contextual information about the AppSync request being authorized. +/// This class provides details about the API, account, and GraphQL operation. +/// +public class AppSyncRequestContext +{ + /// + /// Gets or sets the unique identifier of the AppSync API. + /// + public string? ApiId { get; set; } + + /// + /// Gets or sets the AWS account ID where the AppSync API is deployed. + /// + public string? AccountId { get; set; } + + /// + /// Gets or sets the unique identifier for this specific request. + /// + public string? RequestId { get; set; } + + /// + /// Gets or sets the GraphQL query string containing the operation to be executed. + /// + public string? QueryString { get; set; } + + /// + /// Gets or sets the name of the GraphQL operation to be executed. + /// This corresponds to the operation name in the GraphQL query. + /// + public string? OperationName { get; set; } + + /// + /// Gets or sets the variables passed to the GraphQL operation. + /// Contains key-value pairs of variable names and their values. + /// + public Dictionary? Variables { get; set; } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Channel.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Channel.cs new file mode 100644 index 00000000..156c736a --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Channel.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Channel details including path and segments +/// +public class Channel +{ + /// + /// Provides direct access to the 'Path' attribute within the 'Channel' object. + /// + [JsonPropertyName("path")] + public string? Path { get; set; } + + /// + /// Provides direct access to the 'Segments' attribute within the 'Channel' object. + /// + [JsonPropertyName("segments")] + public string[]? Segments { get; set; } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/ChannelNamespace.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/ChannelNamespace.cs new file mode 100644 index 00000000..9bcc5e6e --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/ChannelNamespace.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Namespace configuration for the channel +/// +public class ChannelNamespace +{ + /// + /// Name of the channel namespace + /// + [JsonPropertyName("name")] + public string? Name { get; set; } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Information.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Information.cs new file mode 100644 index 00000000..79c62a05 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Information.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Represents information about the AppSync event. +/// +public class Information +{ + /// + /// The channel being used for the operation + /// + [JsonPropertyName("channel")] + public Channel? Channel { get; set; } + + /// + /// The namespace of the channel + /// + public ChannelNamespace? ChannelNamespace { get; set; } + + /// + /// The operation being performed (e.g., Publish, Subscribe) + /// + [JsonPropertyName("operation")] + public AppSyncEventsOperation Operation { get; set; } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/RequestContext.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/RequestContext.cs new file mode 100644 index 00000000..1c289354 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/RequestContext.cs @@ -0,0 +1,17 @@ +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Represents information about the HTTP request that triggered the event. +/// +public class RequestContext +{ + /// + /// Gets or sets the headers of the HTTP request. + /// + public Dictionary Headers { get; set; } = new(); + + /// + /// Gets or sets the domain name associated with the request. + /// + public string? DomainName { get; set; } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs new file mode 100644 index 00000000..37fa4663 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs @@ -0,0 +1,74 @@ +namespace AWS.Lambda.Powertools.EventHandler.Internal; + +/// +/// Basic LRU cache implementation +/// +/// +/// Simple LRU cache implementation for caching route resolutions +/// +internal class LruCache where TKey : notnull +{ + private readonly int _capacity; + private readonly Dictionary> _cache; + private readonly LinkedList _lruList; + + internal class CacheItem + { + public TKey Key { get; } + public TValue Value { get; } + + public CacheItem(TKey key, TValue value) + { + Key = key; + Value = value; + } + } + + public LruCache(int capacity) + { + _capacity = capacity; + _cache = new Dictionary>(); + _lruList = new LinkedList(); + } + + public bool TryGet(TKey key, out TValue? value) + { + if (_cache.TryGetValue(key, out var node)) + { + // Move to the front of the list (most recently used) + _lruList.Remove(node); + _lruList.AddFirst(node); + value = node.Value.Value; + return true; + } + + value = default; + return false; + } + + public void Set(TKey key, TValue value) + { + if (_cache.TryGetValue(key, out var existingNode)) + { + _lruList.Remove(existingNode); + _cache.Remove(key); + } + else if (_cache.Count >= _capacity) + { + // Remove least recently used item + var lastNode = _lruList.Last; + _lruList.RemoveLast(); + if (lastNode != null) _cache.Remove(lastNode.Value.Key); + } + + var newNode = new LinkedListNode(new CacheItem(key, value)); + _lruList.AddFirst(newNode); + _cache[key] = newNode; + } + + public void Clear() + { + _cache.Clear(); + _lruList.Clear(); + } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerOptions.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerOptions.cs new file mode 100644 index 00000000..06cb2a2a --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerOptions.cs @@ -0,0 +1,24 @@ +using Amazon.Lambda.Core; + +namespace AWS.Lambda.Powertools.EventHandler.Internal; + +/// +/// Options for registering a route handler +/// +internal class RouteHandlerOptions +{ + /// + /// The path pattern to match against (e.g., "/default/*") + /// + public string Path { get; set; } = "/default/*"; + + /// + /// The handler function to execute when path matches + /// + public required Func> Handler { get; set; } + + /// + /// Whether to aggregate all events into a single handler call + /// + public bool Aggregate { get; set; } = false; +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerRegistry.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerRegistry.cs new file mode 100644 index 00000000..78c8ffe2 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerRegistry.cs @@ -0,0 +1,141 @@ +namespace AWS.Lambda.Powertools.EventHandler.Internal; + +/// +/// Registry for storing route handlers for path-based routing operations. +/// Handles path matching, caching, and handler resolution. +/// +internal class RouteHandlerRegistry +{ + /// + /// Dictionary of registered handlers + /// + private readonly Dictionary> _resolvers = new(); + + /// + /// Cache for resolved routes to improve performance + /// + private readonly LruCache> _resolverCache; + + /// + /// Set to track already logged warnings + /// + private readonly HashSet _warnedPaths = new(); + + /// + /// Initialize a new registry for route handlers + /// + /// Max size of LRU cache (default 100) + public RouteHandlerRegistry(int cacheSize = 100) + { + _resolverCache = new LruCache>(cacheSize); + } + + /// + /// Register a handler for a specific path pattern. + /// + /// Options for the route handler + public void Register(RouteHandlerOptions options) + { + if (!IsValidPath(options.Path)) + { + LogWarning($"The path \"{options.Path}\" is not valid and will be skipped. " + + "Wildcards are allowed only at the end of the path."); + return; + } + + // Clear cache when registering new handlers + _resolverCache.Clear(); + _resolvers[options.Path] = options; + } + + /// + /// Find the most specific handler for a given path. + /// + /// The path to match against registered routes + /// Most specific matching handler or null if no match + public RouteHandlerOptions? ResolveFirst(string? path) + { + if (path != null && _resolverCache.TryGet(path, out var cachedHandler)) + { + return cachedHandler; + } + + // First try for exact match + if (path != null && _resolvers.TryGetValue(path, out var exactMatch)) + { + _resolverCache.Set(path, exactMatch); + return exactMatch; + } + + // Then try wildcard matches, sorted by specificity (most segments first) + var wildcardMatches = _resolvers.Keys + .Where(pattern => path != null && IsWildcardMatch(pattern, path)) + .OrderByDescending(pattern => pattern.Count(c => c == '/')) + .ThenByDescending(pattern => pattern.Length); + + var bestMatch = wildcardMatches.FirstOrDefault(); + + if (bestMatch != null) + { + var handler = _resolvers[bestMatch]; + if (path != null) _resolverCache.Set(path, handler); + return handler; + } + + return null; + } + + /// + /// Get all registered handlers + /// + public IEnumerable> GetAllHandlers() + { + return _resolvers.Values; + } + + /// + /// Check if a path pattern is valid according to routing rules. + /// + private static bool IsValidPath(string path) + { + if (string.IsNullOrWhiteSpace(path) || !path.StartsWith('/')) + return false; + + // Check for invalid wildcard usage + return !path.Contains("*/"); + } + + /// + /// Check if a wildcard pattern matches the given path + /// + private bool IsWildcardMatch(string pattern, string path) + { + if (!pattern.Contains('*')) + return pattern == path; + + var patternSegments = pattern.Split('/'); + var pathSegments = path.Split('/'); + + if (patternSegments.Length > pathSegments.Length) + return false; + + for (var i = 0; i < patternSegments.Length; i++) + { + // If we've reached the wildcard segment, it matches the rest + if (patternSegments[i] == "*") + return true; + + // Otherwise, segments must match exactly + if (patternSegments[i] != pathSegments[i]) + return false; + } + + return patternSegments.Length == pathSegments.Length; + } + + private void LogWarning(string message) + { + if (!_warnedPaths.Add(message)) return; + Console.WriteLine($"Warning: {message}"); + } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/InternalsVisibleTo.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/InternalsVisibleTo.cs new file mode 100644 index 00000000..9e952373 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/InternalsVisibleTo.cs @@ -0,0 +1,18 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("AWS.Lambda.Powertools.EventHandler.Tests")] \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/README.md b/libraries/src/AWS.Lambda.Powertools.EventHandler/README.md new file mode 100644 index 00000000..8c5002b9 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/README.md @@ -0,0 +1,111 @@ +# AWS Lambda Powertools for .NET - Event Handler + +## Event Handler for AWS AppSync real-time events. + +## 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 or sequentially +* Control over event 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 + +1. Install the NuGet package: + +```bash +dotnet add package AWS.Lambda.Powertools.EventHandler --version 1.0.0 +``` +2. Add the `AWS.Lambda.Powertools.EventHandler` namespace to your Lambda function: + +```csharp +using AWS.Lambda.Powertools.EventHandler; +``` +3. Update the AWS Lambda handler to use `AppSyncEventsResolver` + +```csharp +async Task Handler(AppSyncEventsRequest appSyncEvent, ILambdaContext context) +{ + return await app.ResolveAsync(appSyncEvent, context); +} +``` + +### Example + +```csharp +using AWS.Lambda.Powertools.EventHandler; +using Amazon.Lambda.Core; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; +using AWS.Lambda.Powertools.EventHandler.AppSyncEvents; +using AWS.Lambda.Powertools.Logging; + +var app = new AppSyncEventsResolver(); + +app.OnPublishAsync("/default/channel", async (payload) => +{ + Logger.LogInformation("Published to /default/channel with {@payload}", payload); + + if (payload["eventType"].ToString() == "data_2") + { + throw new Exception("Error in /default/channel"); + } + + return "Hello from /default/channel"; +}); + +app.OnPublishAggregateAsync("/default/channel2", async (payload) => +{ + var evt = new List(); + foreach (var item in payload.Events) + { + var pd = new AppSyncEvent + { + Id = item.Id, + Payload = new Dictionary + { + { "demo", "demo" } + } + }; + + if (item.Payload["eventType"].ToString() == "data_2") + { + pd.Payload["message"] = "Hello from /default/channel2 with data_2"; + pd.Payload["data"] = new Dictionary + { + { "key", "value" } + }; + } + + evt.Add(pd); + } + + Logger.LogInformation("Published to /default/channel2 with {@evt}", evt); + return new AppSyncEventsResponse + { + Events = evt + }; +}); + +app.OnSubscribeAsync("/default/*", async (payload) => +{ + Logger.LogInformation("Subscribed to /default/* with {@payload}", payload); + return true; +}); + +async Task Handler(AppSyncEventsRequest appSyncEvent, ILambdaContext context) +{ + return await app.ResolveAsync(appSyncEvent, context); +} + +await LambdaBootstrapBuilder.Create((Func>)Handler, +new DefaultLambdaJsonSerializer()) + .Build() + .RunAsync(); +``` \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AWS.Lambda.Powertools.EventHandler.Tests.csproj b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AWS.Lambda.Powertools.EventHandler.Tests.csproj new file mode 100644 index 00000000..eef47181 --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AWS.Lambda.Powertools.EventHandler.Tests.csproj @@ -0,0 +1,45 @@ + + + + + + AWS.Lambda.Powertools.EventHandler.Tests + AWS.Lambda.Powertools.EventHandler.Tests + net8.0 + enable + enable + + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs new file mode 100644 index 00000000..b4301f93 --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs @@ -0,0 +1,886 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Amazon.Lambda.Core; +using Amazon.Lambda.TestUtilities; +using AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +namespace AWS.Lambda.Powertools.EventHandler.Tests; + +public class AppSyncEventsTests +{ + private readonly AppSyncEventsRequest _appSyncEvent; + + public AppSyncEventsTests() + { + _appSyncEvent = JsonSerializer.Deserialize( + File.ReadAllText("appSyncEventsEvent.json"), + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + })!; + } + + [Fact] + public void Should_Return_Unchanged_Payload_No_Handlers() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + // Act + var result = app.Resolve(_appSyncEvent, lambdaContext); + + // Assert + Assert.Equal(3, result.Events.Count); + Assert.Equal("1", result.Events[0].Id); + Assert.Equal("data_1", result.Events[0].Payload?["event_1"].ToString()); + Assert.Equal("2", result.Events[1].Id); + Assert.Equal("data_2", result.Events[1].Payload?["event_2"].ToString()); + Assert.Equal("3", result.Events[2].Id); + Assert.Equal("data_3", result.Events[2].Payload?["event_3"].ToString()); + } + + [Fact] + public void Should_Return_Unchanged_Payload() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/default/channel", payload => + { + // Handle channel1 events + return payload; + }); + + // Act + var result = app.Resolve(_appSyncEvent, lambdaContext); + + // Assert + Assert.Equal(3, result.Events.Count); + Assert.Equal("1", result.Events[0].Id); + Assert.Equal("data_1", result.Events[0].Payload?["event_1"].ToString()); + Assert.Equal("2", result.Events[1].Id); + Assert.Equal("data_2", result.Events[1].Payload?["event_2"].ToString()); + Assert.Equal("3", result.Events[2].Id); + Assert.Equal("data_3", result.Events[2].Payload?["event_3"].ToString()); + } + + [Fact] + public async Task Should_Return_Unchanged_Payload_Async() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublishAsync("/default/channel", async payload => + { + // Handle channel1 events + return payload; + }); + + // Act + var result = + await app.ResolveAsync(_appSyncEvent, lambdaContext); + + // Assert + Assert.Equal(3, result.Events.Count); + Assert.Equal("1", result.Events[0].Id); + Assert.Equal("data_1", result.Events[0].Payload?["event_1"].ToString()); + Assert.Equal("2", result.Events[1].Id); + Assert.Equal("data_2", result.Events[1].Payload?["event_2"].ToString()); + Assert.Equal("3", result.Events[2].Id); + Assert.Equal("data_3", result.Events[2].Payload?["event_3"].ToString()); + } + + [Fact] + public async Task Should_Handle_Error_In_Event_Processing() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublishAsync("/default/channel", async (payload) => + { + // Throw exception for second event + if (payload.ContainsKey("event_2")) + { + throw new InvalidOperationException("Test error"); + } + + return payload; + }); + + // Act + var result = await app.ResolveAsync(_appSyncEvent, lambdaContext); + + // Assert + if (result.Events != null) + { + Assert.Equal(3, result.Events.Count); + Assert.Equal("1", result.Events[0].Id); + Assert.Equal("data_1", result.Events[0].Payload?["event_1"].ToString()); + Assert.Equal("2", result.Events[1].Id); + Assert.NotNull(result.Events[1].Error); + Assert.Contains("Test error", result.Events[1].Error); + Assert.Equal("3", result.Events[2].Id); + Assert.Equal("data_3", result.Events[2].Payload?["event_3"].ToString()); + } + } + + [Fact] + public async Task Should_Match_Path_With_Wildcard() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + int callCount = 0; + app.OnPublishAsync("/default/*", async (payload) => + { + callCount++; + return new Dictionary { ["wildcard_matched"] = true }; + }); + + // Act + var result = await app.ResolveAsync(_appSyncEvent, lambdaContext); + + // Assert + if (result.Events != null) + { + Assert.Equal(3, result.Events.Count); + Assert.Equal(3, callCount); + Assert.True((bool)(result.Events[0].Payload?["wildcard_matched"] ?? false)); + } + } + + [Fact] + public async Task Should_Authorize_Subscription() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublishAsync("/default/channel", async (payload) => payload); + + app.OnSubscribeAsync("/default/*", async (info) => true); + var subscribeEvent = new AppSyncEventsRequest + { + Info = new Information + { + Channel = new Channel + { + Path = "/default/channel", + Segments = ["default", "channel"] + }, + Operation = AppSyncEventsOperation.Subscribe, + ChannelNamespace = new ChannelNamespace { Name = "default" } + } + }; + // Act + var result = await app.ResolveAsync(subscribeEvent, lambdaContext); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Should_Deny_Subscription() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/default/channel", (payload) => payload); + + app.OnSubscribe("/default/*", (info) => false); + var subscribeEvent = new AppSyncEventsRequest + { + Info = new Information + { + Channel = new Channel { Path = "/default/channel", Segments = ["default", "channel"] }, + Operation = AppSyncEventsOperation.Subscribe, + ChannelNamespace = new ChannelNamespace { Name = "default" } + } + }; + // Act + var result = app.Resolve(subscribeEvent, lambdaContext); + + // Assert + Assert.NotNull(result.Error); + } + + [Fact] + public void Should_Deny_Subscription_On_Exception() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/default/channel", (payload) => payload); + + app.OnSubscribe("/default/*", (info) => { throw new Exception("Authorization error"); }); + + var subscribeEvent = new AppSyncEventsRequest + { + Info = new Information + { + Channel = new Channel { Path = "/default/channel", Segments = ["default", "channel"] }, + Operation = AppSyncEventsOperation.Subscribe, + ChannelNamespace = new ChannelNamespace { Name = "default" } + } + }; + + // Act + var result = app.Resolve(subscribeEvent, lambdaContext); + + // Assert + Assert.Equal("Authorization error", result.Error); + } + + [Fact] + public void Should_Handle_Error_In_Aggregate_Mode() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublishAggregate("/default/channel", + (evt, ctx) => { throw new InvalidOperationException("Aggregate error"); }); + + // Act + var result = app.Resolve(_appSyncEvent, lambdaContext); + + // Assert + Assert.Contains("Aggregate error", result.Error); + } + + [Fact] + public async Task Should_Handle_Error_In_Aggregate_Mode_Async() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublishAggregateAsync("/default/channel", + async (evt, ctx) => { throw new InvalidOperationException("Aggregate error"); }); + + // Act + var result = await app.ResolveAsync(_appSyncEvent, lambdaContext); + + // Assert + Assert.Contains("Aggregate error", result.Error); + } + + [Fact] + public void Should_Handle_TransformingPayload() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/default/channel", (payload) => + { + // Transform each event payload + var transformedPayload = new Dictionary(); + foreach (var key in payload.Keys) + { + transformedPayload[$"transformed_{key}"] = $"transformed_{payload[key]}"; + } + + return transformedPayload; + }); + + // Act + var result = app.Resolve(_appSyncEvent, lambdaContext); + + // Assert + if (result.Events != null) + { + Assert.Equal(3, result.Events.Count); + Assert.Equal("transformed_event_1", result.Events[0].Payload?.Keys.First()); + Assert.Equal("transformed_data_1", result.Events[0].Payload?["transformed_event_1"].ToString()); + } + } + + [Fact] + public async Task Should_Handle_TransformingPayload_Async() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublishAsync("/default/channel", async (payload) => + { + // Transform each event payload + var transformedPayload = new Dictionary(); + foreach (var key in payload.Keys) + { + transformedPayload[$"transformed_{key}"] = $"transformed_{payload[key]}"; + } + + return transformedPayload; + }); + + // Act + var result = await app.ResolveAsync(_appSyncEvent, lambdaContext); + + // Assert + if (result.Events != null) + { + Assert.Equal(3, result.Events.Count); + Assert.Equal("transformed_event_1", result.Events[0].Payload?.Keys.First()); + Assert.Equal("transformed_data_1", result.Events[0].Payload?["transformed_event_1"].ToString()); + } + } + + [Fact] + public async Task Should_Throw_For_Unknown_EventType_Async() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + var unknownEvent = new AppSyncEventsRequest + { + Info = new Information + { + Channel = new Channel { Path = "/default/channel", Segments = ["default", "channel"] }, + Operation = (AppSyncEventsOperation)999, // Unknown operation + ChannelNamespace = new ChannelNamespace { Name = "default" } + } + }; + + // Act & Assert + await Assert.ThrowsAsync(() => + app.ResolveAsync(unknownEvent, lambdaContext)); + } + + [Fact] + public void Should_Throw_For_Unknown_EventType() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + var unknownEvent = new AppSyncEventsRequest + { + Info = new Information + { + Channel = new Channel { Path = "/default/channel", Segments = ["default", "channel"] }, + Operation = (AppSyncEventsOperation)999, // Unknown operation + ChannelNamespace = new ChannelNamespace { Name = "default" } + } + }; + + // Act & Assert + Assert.Throws(() => + app.Resolve(unknownEvent, lambdaContext)); + } + + [Fact] + public void Should_Return_NonDictionary_Values_Wrapped_In_Data() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/default/channel", (payload) => + { + // Return a non-dictionary value + return "string value"; + }); + + // Act + var result = app.Resolve(_appSyncEvent, lambdaContext); + + // Assert + if (result.Events != null) + { + Assert.Equal(3, result.Events.Count); + Assert.Equal("string value", result.Events[0].Payload?["data"].ToString()); + } + } + + [Fact] + public void Should_Skip_Invalid_Path_Registration() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + var handlerCalled = false; + + // Register with invalid path + app.OnPublish("/invalid/*/path", (payload) => + { + handlerCalled = true; + return payload; + }); + + // Act + var result = app.Resolve(_appSyncEvent, lambdaContext); + + // Assert - Should return original payload, handler not called + if (result.Events != null) + { + Assert.Equal(3, result.Events.Count); + Assert.Equal("data_1", result.Events[0].Payload?["event_1"].ToString()); + } + + Assert.False(handlerCalled); + } + + [Fact] + public void Should_Replace_Handler_When_RegisteringTwice() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/default/channel", + (payload) => { return new Dictionary { ["handler"] = "first" }; }); + + app.OnPublish("/default/channel", + (payload) => { return new Dictionary { ["handler"] = "second" }; }); + + // Act + var result = app.Resolve(_appSyncEvent, lambdaContext); + + // Assert - Only second handler should be used + if (result.Events != null) + { + Assert.Equal(3, result.Events.Count); + Assert.Equal("second", result.Events[0].Payload?["handler"].ToString()); + } + } + + [Fact] + public async Task Should_Replace_Handler_When_RegisteringTwice_Async() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublishAsync("/default/channel", + async (payload) => { return new Dictionary { ["handler"] = "first" }; }); + + app.OnPublishAsync("/default/channel", + async (payload) => { return new Dictionary { ["handler"] = "second" }; }); + + // Act + var result = await app.ResolveAsync(_appSyncEvent, lambdaContext); + + // Assert - Only second handler should be used + if (result.Events != null) + { + Assert.Equal(3, result.Events.Count); + Assert.Equal("second", result.Events[0].Payload?["handler"].ToString()); + } + } + + [Fact] + public void Should_Maintain_EventIds_When_Processing() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/default/channel", + (payload) => { return new Dictionary { ["processed"] = true }; }); + + // Act + var result = app.Resolve(_appSyncEvent, lambdaContext); + + // Assert + if (result.Events != null) + { + Assert.Equal(3, result.Events.Count); + Assert.Equal("1", result.Events[0].Id); + Assert.Equal("2", result.Events[1].Id); + Assert.Equal("3", result.Events[2].Id); + } + } + + [Fact] + public async Task Aggregate_Handler_Can_Return_Individual_Results_With_Ids() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublishAggregateAsync("/default/channel13", (payload) => { throw new Exception("My custom exception"); }); + + app.OnPublishAsync("/default/channel12", (payload) => { throw new Exception("My custom exception"); }); + + app.OnPublishAggregateAsync("/default/channel", async (evt) => + { + // Iterate through events and return individual results with IDs + var results = new List(); + + foreach (var eventItem in evt.Events) + { + try + { + if (eventItem.Payload.ContainsKey("event_2")) + { + // Create an error for the second event + results.Add(new AppSyncEvent + { + Id = eventItem.Id, + Error = "Intentional error for event 2" + }); + } + else + { + // Process normally + results.Add(new AppSyncEvent + { + Id = eventItem.Id, + Payload = new Dictionary + { + ["processed"] = true, + ["originalData"] = eventItem.Payload + } + }); + } + } + catch (Exception ex) + { + results.Add(new AppSyncEvent + { + Id = eventItem.Id, + Error = $"{ex.GetType().Name} - {ex.Message}" + }); + } + } + + return new AppSyncEventsResponse { Events = results }; + }); + + // Act + var result = await app.ResolveAsync(_appSyncEvent, lambdaContext); + + // Assert + if (result.Events != null) + { + Assert.Equal(3, result.Events.Count); + Assert.Equal("1", result.Events[0].Id); + Assert.True((bool)(result.Events[0].Payload?["processed"] ?? false)); + Assert.Equal("2", result.Events[1].Id); + Assert.NotNull(result.Events[1].Error); + Assert.Contains("Intentional error for event 2", result.Events[1].Error); + Assert.Equal("3", result.Events[2].Id); + Assert.True((bool)(result.Events[2].Payload?["processed"] ?? false)); + } + } + + [Fact] + public async Task Should_Verify_Ids_Are_Preserved_In_Error_Case() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + // Create handlers that throw exceptions for specific events + app.OnPublishAsync("/default/channel", async (payload) => + { + if (payload.ContainsKey("event_1")) + throw new InvalidOperationException("Error for event 1"); + if (payload.ContainsKey("event_3")) + throw new ArgumentException("Error for event 3"); + return payload; + }); + + // Act + var result = await app.ResolveAsync(_appSyncEvent, lambdaContext); + + // Assert + Assert.Equal(3, result.Events.Count); + Assert.Equal("1", result.Events[0].Id); + Assert.Contains("Error for event 1", result.Events[0].Error); + Assert.Equal("2", result.Events[1].Id); + Assert.Null(result.Events[1].Error); + Assert.Equal("3", result.Events[2].Id); + Assert.Contains("Error for event 3", result.Events[2].Error); + } + + [Fact] + public async Task Should_Match_Most_Specific_Handler_Only() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + int firstHandlerCalls = 0; + int secondHandlerCalls = 0; + + app.OnPublishAsync("/default/channel", async (payload) => + { + firstHandlerCalls++; + return new Dictionary { ["handler"] = "first" }; + }); + + app.OnPublishAsync("/default/*", async (payload) => + { + secondHandlerCalls++; + return new Dictionary { ["handler"] = "second" }; + }); + + // Act + var result = await app.ResolveAsync(_appSyncEvent, lambdaContext); + + // Assert - Only the first (most specific) handler should be called + Assert.Equal(3, result.Events.Count); + Assert.Equal("first", result.Events[0].Payload["handler"].ToString()); + Assert.Equal(3, firstHandlerCalls); + Assert.Equal(0, secondHandlerCalls); + } + + [Fact] + public async Task Should_Handle_Multiple_Keys_In_Payload() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + // Create an event with multiple keys in the payload + var multiKeyEvent = new AppSyncEventsRequest + { + Info = new Information + { + Channel = new Channel { Path = "/default/channel", Segments = ["default", "channel"] }, + Operation = AppSyncEventsOperation.Publish, + ChannelNamespace = new ChannelNamespace { Name = "default" } + }, + Events = + [ + new AppSyncEvent + { + Id = "1", + Payload = new Dictionary + { + ["event_1"] = "data_1", + ["event_1a"] = "data_1a" + } + } + ] + }; + + app.OnPublishAsync("/default/channel", async (payload) => + { + // Check that both keys are present + Assert.Equal("data_1", payload["event_1"]); + Assert.Equal("data_1a", payload["event_1a"]); + + // Return a processed result with both keys + return new Dictionary + { + ["processed_1"] = payload["event_1"], + ["processed_1a"] = payload["event_1a"] + }; + }); + + // Act + var result = await app.ResolveAsync(multiKeyEvent, lambdaContext); + + // Assert + Assert.Single(result.Events); + Assert.Equal("1", result.Events[0].Id); + Assert.Equal("data_1", result.Events[0].Payload["processed_1"]); + Assert.Equal("data_1a", result.Events[0].Payload["processed_1a"]); + } + + [Fact] + public async Task Should_Only_Use_First_Matching_Handler_By_Specificity() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + // Register handlers with different specificity + app.OnPublishAsync("/*", async (payload) => + new Dictionary { ["handler"] = "least-specific" }); + + app.OnPublishAsync("/default/*", async (payload) => + new Dictionary { ["handler"] = "more-specific" }); + + app.OnPublishAsync("/default/channel", async (payload) => + new Dictionary { ["handler"] = "most-specific" }); + + // Act + var result = await app.ResolveAsync(_appSyncEvent, lambdaContext); + + // Assert - Only the most specific handler should be called + Assert.Equal(3, result.Events.Count); + Assert.Equal("most-specific", result.Events[0].Payload["handler"].ToString()); + Assert.Equal("most-specific", result.Events[1].Payload["handler"].ToString()); + Assert.Equal("most-specific", result.Events[2].Payload["handler"].ToString()); + } + + [Fact] + public async Task Should_Fallback_To_Less_Specific_Handler_If_No_Exact_Match() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + // Create an event with a path that has no exact match + var fallbackEvent = new AppSyncEventsRequest + { + Info = new Information + { + Channel = new Channel { Path = "/default/specific/path", Segments = ["default", "specific", "path"] }, + Operation = AppSyncEventsOperation.Publish, + ChannelNamespace = new ChannelNamespace { Name = "default" } + }, + Events = + [ + new AppSyncEvent + { + Id = "1", + Payload = new Dictionary { ["key"] = "value" } + } + ] + }; + + app.OnPublishAsync("/default/*", async (payload) => + new Dictionary { ["handler"] = "wildcard-handler" }); + + // Act + var result = await app.ResolveAsync(fallbackEvent, lambdaContext); + + // Assert + Assert.Single(result.Events); + Assert.Equal("wildcard-handler", result.Events[0].Payload["handler"].ToString()); + } + + [Fact] + public async Task Should_Return_Null_When_Subscribing_To_Path_Without_Publish_Handler() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + // Only set up a subscribe handler without corresponding publish handler + app.OnSubscribeAsync("/subscribe-only", async (info) => true); + + var subscribeEvent = new AppSyncEventsRequest + { + Info = new Information + { + Channel = new Channel { Path = "/subscribe-only", Segments = ["subscribe-only"] }, + Operation = AppSyncEventsOperation.Subscribe, + ChannelNamespace = new ChannelNamespace { Name = "default" } + } + }; + + // Act + var result = await app.ResolveAsync(subscribeEvent, lambdaContext); + + // Assert + Assert.Null(result); + } + + [Theory] + [InlineData("/default/channel", "/default/channel1")] + [InlineData("/default/channel3", "/default/channel")] + public void Should_Return_Null_When_Subscribing_To_Path_With_No_Match_Publish_Handler(string publishPath, + string subscribePath) + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish(publishPath, (payload) => payload); + app.OnSubscribe(subscribePath, (info) => true); + + var subscribeEvent = new AppSyncEventsRequest + { + Info = new Information + { + Channel = new Channel { Path = subscribePath, Segments = ["default", "channel"] }, + Operation = AppSyncEventsOperation.Subscribe, + ChannelNamespace = new ChannelNamespace { Name = "default" } + } + }; + + // Act + var result = app.Resolve(subscribeEvent, lambdaContext); + + // Assert + Assert.Null(result); + } + + [Theory] + [InlineData("/default/channel", "/default/channel")] + [InlineData("/default/channel", "/default/*")] + [InlineData("/default/test", "/default/*")] + [InlineData("/default/*", "/default/*")] + public async Task Should_Return_UnauthorizedException_When_Throwing_UnauthorizedException(string publishPath, + string subscribePath) + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublishAsync(publishPath, async (payload) => payload); + app.OnSubscribeAsync(subscribePath, + (info, lambdaContext) => { throw new UnauthorizedException("OOPS"); }); + + var subscribeEvent = new AppSyncEventsRequest + { + Info = new Information + { + Channel = new Channel { Path = subscribePath, Segments = ["default", "channel"] }, + Operation = AppSyncEventsOperation.Subscribe, + ChannelNamespace = new ChannelNamespace { Name = "default" } + } + }; + + // Act && Assert + await Assert.ThrowsAsync(() => + app.ResolveAsync(subscribeEvent, lambdaContext)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Should_Return_UnauthorizedException_When_Throwing_UnauthorizedException_Publish(bool aggreate) + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + if (aggreate) + { + app.OnPublishAggregateAsync("/default/channel", (payload) => throw new UnauthorizedException("OOPS")); + } + else + { + app.OnPublishAsync("/default/channel", (payload) => throw new UnauthorizedException("OOPS")); + } + + var subscribeEvent = new AppSyncEventsRequest + { + Info = new Information + { + Channel = new Channel { Path = "/default/channel", Segments = ["default", "channel"] }, + Operation = AppSyncEventsOperation.Publish, + ChannelNamespace = new ChannelNamespace { Name = "default" } + }, + Events = + [ + new AppSyncEvent + { + Id = "1", + Payload = new Dictionary { ["key"] = "value" } + } + ] + }; + + // Act && Assert + await Assert.ThrowsAsync(() => + app.ResolveAsync(subscribeEvent, lambdaContext)); + } +} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/RouteHandlerRegistryTests.cs b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/RouteHandlerRegistryTests.cs new file mode 100644 index 00000000..92c9da3a --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/RouteHandlerRegistryTests.cs @@ -0,0 +1,226 @@ +using AWS.Lambda.Powertools.EventHandler.Internal; + +namespace AWS.Lambda.Powertools.EventHandler.Tests; + +public class RouteHandlerRegistryTests +{ + [Theory] + [InlineData("/default/channel", true)] + [InlineData("/default/*", true)] + [InlineData("/*", true)] + [InlineData("/a/b/c", true)] + [InlineData("/a/*/c", false)] // Wildcard in the middle is invalid + [InlineData("*/default", false)] // Wildcard at the beginning is invalid + [InlineData("default/*", false)] // Not starting with slash + [InlineData("", false)] // Empty path + [InlineData(null, false)] // Null path + public void IsValidPath_ShouldValidateCorrectly(string path, bool expected) + { + // Create a private method accessor to test private IsValidPath method + var registry = new RouteHandlerRegistry(); + var isValidPathMethod = typeof(RouteHandlerRegistry) + .GetMethod("IsValidPath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + // Act + var result = (bool)isValidPathMethod.Invoke(null, new object[] { path }); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void Register_ShouldNotAddInvalidPath() + { + // Arrange + var registry = new RouteHandlerRegistry(); + + // Act + registry.Register(new RouteHandlerOptions + { + Path = "/invalid/*/path", // Invalid path with wildcard in the middle + Handler = (_, _) => Task.FromResult(null) + }); + + // Assert - Try to resolve an invalid path + var result = registry.ResolveFirst("/invalid/test/path"); + Assert.Null(result); // Should not find any handler + } + + [Fact] + public void Register_ShouldReplaceExistingHandler() + { + // Arrange + var registry = new RouteHandlerRegistry(); + int firstHandlerCalled = 0; + int secondHandlerCalled = 0; + + // Act + registry.Register(new RouteHandlerOptions + { + Path = "/test/path", + Handler = (_, _) => { + firstHandlerCalled++; + return Task.FromResult("first"); + } + }); + + registry.Register(new RouteHandlerOptions + { + Path = "/test/path", // Same path, should replace first handler + Handler = (_, _) => { + secondHandlerCalled++; + return Task.FromResult("second"); + } + }); + + // Assert + var handler = registry.ResolveFirst("/test/path"); + Assert.NotNull(handler); + var result = handler.Handler(null, null).Result; + Assert.Equal("second", result); + Assert.Equal(0, firstHandlerCalled); + Assert.Equal(1, secondHandlerCalled); + } + + [Fact] + public async Task ResolveFirst_ShouldReturnMostSpecificHandler() + { + // Arrange + var registry = new RouteHandlerRegistry(); + + registry.Register(new RouteHandlerOptions + { + Path = "/*", + Handler = (_, _) => Task.FromResult("least-specific") + }); + + registry.Register(new RouteHandlerOptions + { + Path = "/default/*", + Handler = (_, _) => Task.FromResult("more-specific") + }); + + registry.Register(new RouteHandlerOptions + { + Path = "/default/channel", + Handler = (_, _) => Task.FromResult("most-specific") + }); + + // Act - Test various paths + var exactMatch = registry.ResolveFirst("/default/channel"); + var wildcardMatch = registry.ResolveFirst("/default/something"); + var rootMatch = registry.ResolveFirst("/something"); + + // Assert + Assert.NotNull(exactMatch); + Assert.Equal("most-specific", await exactMatch.Handler(null, null)); + + Assert.NotNull(wildcardMatch); + Assert.Equal("more-specific", await wildcardMatch.Handler(null, null)); + + Assert.NotNull(rootMatch); + Assert.Equal("least-specific", await rootMatch.Handler(null, null)); + } + + [Fact] + public void ResolveFirst_ShouldReturnNullWhenNoMatch() + { + // Arrange + var registry = new RouteHandlerRegistry(); + + registry.Register(new RouteHandlerOptions + { + Path = "/default/*", + Handler = (_, _) => Task.FromResult("test") + }); + + // Act + var result = registry.ResolveFirst("/other/path"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ResolveFirst_ShouldUseCacheForRepeatedPaths() + { + // Arrange + var registry = new RouteHandlerRegistry(); + int handlerCallCount = 0; + + registry.Register(new RouteHandlerOptions + { + Path = "/test/*", + Handler = (_, _) => { + handlerCallCount++; + return Task.FromResult("cached"); + } + }); + + // Act - Resolve the same path multiple times + var first = registry.ResolveFirst("/test/path"); + var firstResult = first.Handler(null, null).Result; + + // Should use cached result + var second = registry.ResolveFirst("/test/path"); + var secondResult = second.Handler(null, null).Result; + + // Assert + Assert.Equal("cached", firstResult); + Assert.Equal("cached", secondResult); + Assert.Equal(2, handlerCallCount); // Handler should be called twice because handlers are executed + // even though the path resolution is cached + + // The objects should be the same instance + Assert.Same(first, second); + } + + [Fact] + public void LRUCache_ShouldEvictOldestItemsWhenFull() + { + // Arrange - Create a cache with size 2 + var cache = new LruCache(2); + + // Act + cache.Set("key1", "value1"); + cache.Set("key2", "value2"); + cache.Set("key3", "value3"); // Should evict key1 + + // Assert + Assert.False(cache.TryGet("key1", out _)); // Should be evicted + Assert.True(cache.TryGet("key2", out var value2)); + Assert.Equal("value2", value2); + Assert.True(cache.TryGet("key3", out var value3)); + Assert.Equal("value3", value3); + } + + [Fact] + public void IsWildcardMatch_ShouldMatchPathsCorrectly() + { + // Arrange + var registry = new RouteHandlerRegistry(); + var isWildcardMatchMethod = typeof(RouteHandlerRegistry) + .GetMethod("IsWildcardMatch", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + // Test cases + var testCases = new[] + { + (pattern: "/default/*", path: "/default/channel", expected: true), + (pattern: "/default/*", path: "/default/other", expected: true), + (pattern: "/default/*", path: "/default/nested/path", expected: true), + (pattern: "/default/channel", path: "/default/channel", expected: true), + (pattern: "/default/channel", path: "/default/other", expected: false), + (pattern: "/*", path: "/anything", expected: true), + (pattern: "/*", path: "/default/nested/deep", expected: true) + }; + + foreach (var (pattern, path, expected) in testCases) + { + // Act + var result = (bool)isWildcardMatchMethod.Invoke(registry, new object[] { pattern, path }); + + // Assert + Assert.Equal(expected, result); + } + } +} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/appSyncEventsEvent.json b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/appSyncEventsEvent.json new file mode 100644 index 00000000..1334b5ac --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/appSyncEventsEvent.json @@ -0,0 +1,76 @@ +{ + "identity":"None", + "result":"None", + "request":{ + "headers": { + "x-forwarded-for": "1.1.1.1, 2.2.2.2", + "cloudfront-viewer-country": "US", + "cloudfront-is-tablet-viewer": "false", + "via": "2.0 xxxxxxxxxxxxxxxx.cloudfront.net (CloudFront)", + "cloudfront-forwarded-proto": "https", + "origin": "https://us-west-1.console.aws.amazon.com", + "content-length": "217", + "accept-language": "en-US,en;q=0.9", + "host": "xxxxxxxxxxxxxxxx.appsync-api.us-west-1.amazonaws.com", + "x-forwarded-proto": "https", + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36", + "accept": "*/*", + "cloudfront-is-mobile-viewer": "false", + "cloudfront-is-smarttv-viewer": "false", + "accept-encoding": "gzip, deflate, br", + "referer": "https://us-west-1.console.aws.amazon.com/appsync/home?region=us-west-1", + "content-type": "application/json", + "sec-fetch-mode": "cors", + "x-amz-cf-id": "3aykhqlUwQeANU-HGY7E_guV5EkNeMMtwyOgiA==", + "x-amzn-trace-id": "Root=1-5f512f51-fac632066c5e848ae714", + "authorization": "eyJraWQiOiJScWFCSlJqYVJlM0hrSnBTUFpIcVRXazNOW...", + "sec-fetch-dest": "empty", + "x-amz-user-agent": "AWS-Console-AppSync/", + "cloudfront-is-desktop-viewer": "true", + "sec-fetch-site": "cross-site", + "x-forwarded-port": "443" + }, + "domainName":"None" + }, + "info":{ + "channel":{ + "path":"/default/channel", + "segments":[ + "default", + "channel" + ] + }, + "channelNamespace":{ + "name":"default" + }, + "operation":"PUBLISH" + }, + "error":"None", + "prev":"None", + "stash":{ + + }, + "outErrors":[ + + ], + "events":[ + { + "payload":{ + "event_1":"data_1" + }, + "id":"1" + }, + { + "payload":{ + "event_2":"data_2" + }, + "id":"2" + }, + { + "payload":{ + "event_3":"data_3" + }, + "id":"3" + } + ] +} \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index bd8426e8..ec2179c0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -21,6 +21,8 @@ nav: - core/metrics.md - core/metrics-v2.md - core/tracing.md + - Event Handler: + - core/event_handler/appsync_events.md - Utilities: - utilities/parameters.md - utilities/idempotency.md diff --git a/version.json b/version.json index ebe899b4..6a04f47a 100644 --- a/version.json +++ b/version.json @@ -8,6 +8,7 @@ "Utilities": { "Parameters": "1.3.0", "Idempotency": "1.3.0", - "BatchProcessing": "1.2.0" + "BatchProcessing": "1.2.0", + "EventHandler": "1.0.0" } }