From d7e34f301eac2fcec8bffe0f53df0e794676fa8b Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Tue, 15 Apr 2025 17:12:46 +0100 Subject: [PATCH 01/16] initial commit, added new project and tests --- libraries/AWS.Lambda.Powertools.sln | 30 ++ .../AWS.Lambda.Powertools.EventHandler.csproj | 19 + .../AppSyncEvents/AppSyncAuthorizerEvent.cs | 26 + .../AppSyncEvents/AppSyncAuthorizerResult.cs | 34 ++ .../AppSyncEvents/AppSyncCognitoIdentity.cs | 42 ++ .../AppSyncEvents/AppSyncEventsResolver.cs | 305 ++++++++++++ .../AppSyncEvents/AppSyncIamIdentity.cs | 47 ++ .../AppSyncEvents/AppSyncLambdaIdentity.cs | 13 + .../AppSyncEvents/AppSyncOidcIdentity.cs | 22 + .../AppSyncEvents/AppSyncRequestContext.cs | 40 ++ .../AppSyncEvents/AppSyncResolverEvent.cs | 60 +++ .../AppSyncResolverEventsResponse.cs | 11 + .../AppSyncResolverEventsResult.cs | 19 + .../AppSyncEvents/Channel.cs | 17 + .../AppSyncEvents/ChannelNamespace.cs | 9 + .../AppSyncEvents/Information.cs | 61 +++ .../AppSyncEvents/RequestContext.cs | 17 + .../Internal/LRUCache.cs | 101 ++++ .../Internal/RouteHandlerOptions.cs | 24 + .../Internal/RouteHandlerRegistry.cs | 164 +++++++ .../InternalsVisibleTo.cs | 18 + .../README.md | 1 + ...ambda.Powertools.EventHandler.Tests.csproj | 41 ++ .../AppSyncEventsTests.cs | 449 ++++++++++++++++++ .../RouteHandlerRegistryTests.cs | 228 +++++++++ .../appSyncEventsEvent.json | 76 +++ 26 files changed, 1874 insertions(+) create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AWS.Lambda.Powertools.EventHandler.csproj create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncAuthorizerEvent.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncAuthorizerResult.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncCognitoIdentity.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncIamIdentity.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncLambdaIdentity.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncOidcIdentity.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncRequestContext.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEvent.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEventsResponse.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEventsResult.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Channel.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/ChannelNamespace.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Information.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/RequestContext.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerOptions.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerRegistry.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/InternalsVisibleTo.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/README.md create mode 100644 libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AWS.Lambda.Powertools.EventHandler.Tests.csproj create mode 100644 libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs create mode 100644 libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/RouteHandlerRegistryTests.cs create mode 100644 libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/appSyncEventsEvent.json 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/AppSyncAuthorizerEvent.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncAuthorizerEvent.cs new file mode 100644 index 00000000..7bc29f7b --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncAuthorizerEvent.cs @@ -0,0 +1,26 @@ +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Represents an AWS AppSync authorization event that is sent to a Lambda authorizer +/// for evaluating access permissions to the GraphQL API. +/// +public class AppSyncAuthorizerEvent +{ + /// + /// Gets or sets the authorization token received from the client request. + /// This token is used to make authorization decisions. + /// + public string AuthorizationToken { get; set; } + + /// + /// Gets or sets the headers from the client request. + /// Contains key-value pairs of HTTP header names and their values. + /// + public Dictionary RequestHeaders { get; set; } + + /// + /// Gets or sets the context information about the AppSync request. + /// Contains metadata about the API and the GraphQL operation being executed. + /// + public AppSyncRequestContext RequestContext { get; set; } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncAuthorizerResult.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncAuthorizerResult.cs new file mode 100644 index 00000000..642c64b3 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncAuthorizerResult.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Represents the authorization result returned by a Lambda authorizer to AWS AppSync +/// containing authorization decisions and optional context for the GraphQL API. +/// +public class AppSyncAuthorizerResult +{ + /// + /// Indicates if the request is authorized + /// + [JsonPropertyName("isAuthorized")] + public bool IsAuthorized { get; set; } + + /// + /// Custom context to pass to resolvers, only supports key-value pairs. + /// + [JsonPropertyName("resolverContext")] + public Dictionary ResolverContext { get; set; } + + /// + /// List of fields that are denied access + /// + [JsonPropertyName("deniedFields")] + public IEnumerable DeniedFields { get; set; } + + /// + /// The number of seconds that the response should be cached for + /// + [JsonPropertyName("ttlOverride")] + public int? TtlOverride { get; set; } +} \ No newline at end of file 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..51ef1ed6 --- /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/AppSyncEventsResolver.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs new file mode 100644 index 00000000..180f9b59 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs @@ -0,0 +1,305 @@ +using System.Text.RegularExpressions; +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; + + public AppSyncEventsResolver() + { + _publishRoutes = new RouteHandlerRegistry(); + _subscribeRoutes = new RouteHandlerRegistry(); + } + + + /// + /// Registers a handler for publish events on a specific channel path + /// Processes each event in the payload individually + /// + public AppSyncEventsResolver OnPublish(string path, Func, Task> handler, + bool aggregate = false) + { + return OnPublish(path, async (evt, context) => + { + var tasks = evt.Events.Select(eventItem => + ProcessSingleEvent(eventItem.Payload, handler) + ).ToList(); + + var results = await Task.WhenAll(tasks); + return results; + }, aggregate); + } + + public AppSyncEventsResolver OnPublish(string path, + Func> handler, bool aggregate = false) + { + _publishRoutes.Register(new RouteHandlerOptions + { + Path = path, + Handler = handler, + Aggregate = aggregate + }); + + return this; + } + + public AppSyncEventsResolver OnSubscribe(string path, Func> handler) + { + return OnSubscribe(path, (evt, _) => handler(ExtractSubscriptionInfo(evt))); + } + + public AppSyncEventsResolver OnSubscribe(string path, + Func> handler) + { + _subscribeRoutes.Register(new RouteHandlerOptions + { + Path = path, + Handler = handler + }); + + return this; + } + + public async Task Resolve(AppSyncResolverEvent 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(AppSyncResolverEvent appsyncEvent, + ILambdaContext context) + { + var channelPath = appsyncEvent.Info.Channel.Path; + var matchingHandlers = _publishRoutes.ResolveAll(channelPath); + + if (matchingHandlers.Count == 0) + { + return new AppSyncResolverEventsResponse + { + Events = appsyncEvent.Events.Select(e => + new AppSyncResolverEventsResult + { + Id = e.id, + Payload = e.Payload + }).ToList() + }; + } + + var results = new List(); + + // Process each matching handler + foreach (var handlerOptions in matchingHandlers) + { + try + { + var handler = handlerOptions.Handler; + var aggregate = handlerOptions.Aggregate; + + // Call handler once per registered handler + var handlerResult = await handler(appsyncEvent, context); + + if (aggregate) + { + // For aggregate mode, return a single result + string error; + var payload = ConvertToPayload(handlerResult, out error); + results.Add(new AppSyncResolverEventsResult + { + Id = Guid.NewGuid().ToString(), + Payload = payload, + Error = error + }); + } + else if (handlerResult is IEnumerable resultArray) + { + // For non-aggregate mode with array result + var eventItems = appsyncEvent.Events.ToArray(); + var i = 0; + + foreach (var item in resultArray) + { + var id = i < eventItems.Length ? eventItems[i].id : Guid.NewGuid().ToString(); + + // Convert payload and check for errors + string errorMessage; + var payload = ConvertToPayload(item, out errorMessage); + + if (errorMessage != null) + { + results.Add(new AppSyncResolverEventsResult + { + Id = id, + Error = errorMessage + }); + } + else + { + results.Add(new AppSyncResolverEventsResult + { + Id = id, + Payload = payload + }); + } + + i++; + } + } + else + { + string error; + var payload = ConvertToPayload(handlerResult, out error); + results.Add(new AppSyncResolverEventsResult + { + Id = appsyncEvent.Events.FirstOrDefault()?.id ?? Guid.NewGuid().ToString(), + Payload = payload, + Error = error + }); + } + } + catch (Exception ex) + { + results.Add(FormatErrorResponse(ex, Guid.NewGuid().ToString())); + } + } + + return new AppSyncResolverEventsResponse { Events = results }; + } + + private async Task HandleSubscribeEvent(AppSyncResolverEvent appsyncEvent, + ILambdaContext context) + { + var channelPath = appsyncEvent.Info.Channel.Path; + var matchingHandlers = _subscribeRoutes.ResolveAll(channelPath); + + if (matchingHandlers.Count == 0) + { + return new AppSyncResolverEventsResponse { Authorized = false }; + } + + try + { + foreach (var handlerOptions in matchingHandlers) + { + var result = await handlerOptions.Handler(appsyncEvent, context); + + // If handler returns false, deny subscription + if (!result) + { + return new AppSyncResolverEventsResponse { Authorized = false }; + } + } + + return new AppSyncResolverEventsResponse { Authorized = true }; + } + catch (Exception ex) + { + context.Logger.LogLine($"Error in subscribe handler: {ex.Message}"); + return new AppSyncResolverEventsResponse { Authorized = false }; + } + } + + +// Helper to process a single event and handle exceptions + private async Task ProcessSingleEvent(Dictionary payload, + Func, Task> handler) + { + try + { + return await handler(payload); + } + catch (Exception ex) + { + return new Dictionary { ["error"] = ex.Message }; + } + } + + + private Information ExtractSubscriptionInfo(AppSyncResolverEvent appsyncEvent) + { + return new Information + { + Channel = new Channel + { + Path = appsyncEvent.Info.Channel.Path + } + }; + } + + 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 AppSyncResolverEventsResult FormatErrorResponse(Exception ex, string id) + { + return new AppSyncResolverEventsResult + { + Id = id, + Error = $"{ex.GetType().Name} - {ex.Message}" + }; + } + + private List FindMatchingPaths(IEnumerable registeredPaths, string channelPath) + { + return registeredPaths.Where(pattern => IsMatch(pattern, channelPath)).ToList(); + } + + private bool IsMatch(string pattern, string path) + { + if (pattern == path) + { + return true; + } + + // Convert wildcards to regex patterns + var regexPattern = "^" + Regex.Escape(pattern) + .Replace("\\*\\*", ".*") // ** matches any segments + .Replace("\\*", "[^/]*") // * matches anything within a segment + + "$"; + + return Regex.IsMatch(path, regexPattern); + } + + private bool IsPublishEvent(AppSyncResolverEvent appsyncEvent) + { + return appsyncEvent.Info.Operation == AppsyncEventsOperation.Publish; + } + + private bool IsSubscribeEvent(AppSyncResolverEvent appsyncEvent) + { + return appsyncEvent.Info.Operation == AppsyncEventsOperation.Subscribe; + } +} \ 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..540dfce5 --- /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..46388670 --- /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..2708d3be --- /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..890aa370 --- /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/AppSyncResolverEvent.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEvent.cs new file mode 100644 index 00000000..38e2a53d --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEvent.cs @@ -0,0 +1,60 @@ +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Represents the event payload received from AWS AppSync. +/// +public class AppSyncResolverEvent +{ + // /// + // /// Gets or sets the input arguments for the GraphQL operation. + // /// + // public TArguments Arguments { get; set; } + + /// + /// 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. + /// + public object? Source { get; set; } + + /// + /// Gets or sets information about the HTTP request that triggered the event. + /// + public RequestContext Request { get; set; } = new(); + + /// + /// Gets or sets information about the previous state of the data before the operation was executed. + /// + public object Prev { get; set; } + + /// + /// Gets or sets information about the GraphQL operation being executed. + /// + public Information Info { get; set; } + + /// + /// Gets or sets additional information that can be passed between Lambda functions during an AppSync pipeline. + /// + public Dictionary Stash { get; set; } + + public string Error { get; set; } + + public object[] OutErrors { get; set; } + + public Events[] Events { get; set; } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEventsResponse.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEventsResponse.cs new file mode 100644 index 00000000..6c3c747b --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEventsResponse.cs @@ -0,0 +1,11 @@ +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +public class AppSyncResolverEventsResponse +{ + /// + /// Collection of event results + /// + public List Events { get; set; } = new(); + + public bool Authorized { get; set; } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEventsResult.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEventsResult.cs new file mode 100644 index 00000000..b4dcd8b6 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEventsResult.cs @@ -0,0 +1,19 @@ +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +public class AppSyncResolverEventsResult +{ + /// + /// Payload data when operation succeeds + /// + public Dictionary? Payload { get; set; } + + /// + /// Error message when operation fails + /// + public string? Error { get; set; } + + /// + /// Unique identifier for the event + /// + public string Id { get; set; } = string.Empty; +} \ 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..d9655f06 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Channel.cs @@ -0,0 +1,17 @@ +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. + /// + public string Path { get; set; } + + /// + /// Provides direct access to the 'Segments' attribute within the 'Channel' object. + /// + 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..ffe99b15 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/ChannelNamespace.cs @@ -0,0 +1,9 @@ +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Namespace configuration for the channel +/// +public class ChannelNamespace +{ + 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..c451a0ab --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Information.cs @@ -0,0 +1,61 @@ +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Represents information about the GraphQL operation being executed. +/// +public class Information +{ + /// + /// Gets or sets the name of the GraphQL field being executed. + /// + public string FieldName { get; set; } + + /// + /// Gets or sets a list of fields being selected in the GraphQL operation. + /// + public List SelectionSetList { get; set; } + + /// + /// Gets or sets the GraphQL selection set for the operation. + /// + public string SelectionSetGraphQL { get; set; } + + /// + /// Gets or sets the variables passed to the GraphQL operation. + /// + public Dictionary Variables { get; set; } + + /// + /// Gets or sets the parent type name for the GraphQL operation. + /// + public string ParentTypeName { get; set; } + + public Channel Channel { get; set; } + public ChannelNamespace ChannelNamespace { get; set; } + + /// + /// The operation being performed (e.g., Publish, Subscribe) + /// + public AppsyncEventsOperation Operation { get; set; } +} + +public enum AppsyncEventsOperation +{ + /// + /// Represents a subscription operation. + /// + Subscribe, + + /// + /// Represents a publish operation. + /// + Publish +} + +public class Events +{ + public Dictionary Payload { get; set; } + + public string id { get; set; } +} + 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..59b9b062 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs @@ -0,0 +1,101 @@ +using System.Collections.Concurrent; + +namespace AWS.Lambda.Powertools.EventHandler.Internal; + +/// +/// Basic LRU cache implementation +/// +internal class LRUCache where TKey : notnull +{ + private readonly int _capacity; + private readonly ConcurrentDictionary> _cache; + private readonly LinkedList _lruList; + private readonly object _lock = new(); + + /// + /// Initialize LRU cache with specified capacity + /// + public LRUCache(int capacity) + { + _capacity = capacity; + _cache = new ConcurrentDictionary>(); + _lruList = new LinkedList(); + } + + /// + /// Add or update a key-value pair in the cache + /// + public void Add(TKey key, TValue value) + { + lock (_lock) + { + if (_cache.TryGetValue(key, out LinkedListNode node)) + { + // Move existing item to front of list + _lruList.Remove(node); + node.Value.Value = value; + _lruList.AddFirst(node); + } + else + { + // Trim cache if at capacity + if (_cache.Count >= _capacity && _lruList.Last != null) + { + _cache.TryRemove(_lruList.Last.Value.Key, out _); + _lruList.RemoveLast(); + } + + // Add new item to front + var cacheItem = new LRUCacheItem { Key = key, Value = value }; + var newNode = new LinkedListNode(cacheItem); + _lruList.AddFirst(newNode); + _cache[key] = newNode; + } + } + } + + /// + /// Try to get a value from the cache + /// + public bool TryGetValue(TKey key, out TValue value) + { + if (_cache.TryGetValue(key, out var node)) + { + lock (_lock) + { + // Move accessed item to front of list + _lruList.Remove(node); + _lruList.AddFirst(node); + } + value = node.Value.Value; + return true; + } + + value = default; + return false; + } + + /// + /// Get or create a value in the cache + /// + public TValue GetOrAdd(TKey key, Func valueFactory) + { + if (TryGetValue(key, out var value)) + { + return value; + } + + var newValue = valueFactory(key); + Add(key, newValue); + return newValue; + } + + /// + /// Helper class for LRU cache items + /// + private class LRUCacheItem + { + public TKey Key { get; set; } + public TValue Value { get; set; } + } +} \ 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..e4c640d6 --- /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 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..eeb5cdbd --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerRegistry.cs @@ -0,0 +1,164 @@ +using System.Text.RegularExpressions; + +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, keyed by regex pattern + /// + private readonly Dictionary> _resolvers = new(); + + /// + /// Cache for resolved routes to improve performance + /// + private readonly LRUCache> _resolverCache; + + /// + /// Set to track already logged warnings to prevent duplicates + /// + 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) + { + LogDebug($"Registering route handler for path \"{options.Path}\" with aggregate \"{options.Aggregate}\""); + + if (!IsValidPath(options.Path)) + { + LogWarning($"The path \"{options.Path}\" is not valid and will be skipped. " + + "A path should always have a namespace starting with \"/\". A path can have multiple namespaces, " + + "all separated by \"/\". Wildcards are allowed only at the end of the path."); + return; + } + + string regex = PathToRegexString(options.Path); + + if (_resolvers.ContainsKey(regex)) + { + LogWarning($"A route handler for path \"{options.Path}\" is already registered. " + + "The previous handler will be replaced."); + } + + _resolvers[regex] = 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 Resolve(string path) + { + // First check cache + if (_resolverCache.TryGetValue(path, out var cachedHandler)) + { + return cachedHandler; + } + + LogDebug($"Resolving handler for path \"{path}\""); + + RouteHandlerOptions mostSpecificHandler = null; + int mostSpecificRouteLength = 0; + + foreach (var (pattern, handlerOptions) in _resolvers) + { + if (Regex.IsMatch(path, pattern)) + { + // Calculate specificity (length of path minus wildcard) + int specificityLength = handlerOptions.Path.Length - + (handlerOptions.Path.EndsWith("*") ? 1 : 0); + + if (specificityLength > mostSpecificRouteLength) + { + mostSpecificRouteLength = specificityLength; + mostSpecificHandler = handlerOptions; + _resolverCache.Add(path, handlerOptions); + } + } + } + + // Log warning if no handler found + if (mostSpecificHandler == null && !_warnedPaths.Contains(path)) + { + LogWarning($"No route handler found for path \"{path}\"."); + _warnedPaths.Add(path); + } + + return mostSpecificHandler; + } + + /// + /// Find all handlers that match the given path. + /// Returns them sorted by specificity (most specific first). + /// + /// Path to match + /// List of matching handlers in order of specificity + public List> ResolveAll(string path) + { + var matches = new List<(RouteHandlerOptions Handler, int Specificity)>(); + + foreach (var (pattern, handlerOptions) in _resolvers) + { + if (Regex.IsMatch(path, pattern)) + { + int specificityLength = handlerOptions.Path.Length - + (handlerOptions.Path.EndsWith("*") ? 1 : 0); + matches.Add((handlerOptions, specificityLength)); + } + } + + return matches + .OrderByDescending(x => x.Specificity) + .Select(x => x.Handler) + .ToList(); + } + + /// + /// Check if a path pattern is valid according to routing rules. + /// + /// Path to validate + /// Whether the path is valid + public static bool IsValidPath(string path) + { + if (path == "/*") return true; + return Regex.IsMatch(path, @"^\/([^\/\*]+)(\/[^\/\*]+)*(\/\*)?$"); + } + + /// + /// Converts a path pattern to a regex string for matching. + /// + /// Path pattern to convert + /// Regular expression string + public static string PathToRegexString(string path) + { + string escapedPath = Regex.Escape(path); + return $"^{escapedPath.Replace("\\*", ".*")}$"; + } + + private void LogDebug(string message) + { + Console.WriteLine(message); + } + + private void LogWarning(string message) + { + 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..c89d2af9 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/README.md @@ -0,0 +1 @@ +# AWS Lambda Powertools for .NET - Event Handler \ 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..750ab553 --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AWS.Lambda.Powertools.EventHandler.Tests.csproj @@ -0,0 +1,41 @@ + + + + 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..2e4ce474 --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs @@ -0,0 +1,449 @@ +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 AppSyncResolverEvent? _appSyncEvent; + + public AppSyncEventsTests() + { + _appSyncEvent = JsonSerializer.Deserialize( + File.ReadAllText("appSyncEventsEvent.json"), + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }); + } + + [Fact] + public async Task Should_Return_Unchanged_Payload_No_Handlers() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + // Act + var result = + await 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() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/default/channel", async (payload) => + { + // Handle channel1 events + return payload; + }); + + // Act + var result = + await 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_Handle_Error_In_Event_Processing() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/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.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.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_Process_Events_In_Aggregate() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/default/channel", async (evt, ctx) => + { + // Create aggregate result from all events + return new Dictionary + { + ["combined"] = true, + ["count"] = evt.Events.Count(), + ["ids"] = string.Join(",", evt.Events.Select(e => e.id)) + }; + }, aggregate: true); + + // Act + var result = await app.Resolve(_appSyncEvent, lambdaContext); + + // Assert + Assert.Single(result.Events); + Assert.Equal("3", result.Events[0].Payload["count"].ToString()); + Assert.Equal("1,2,3", result.Events[0].Payload["ids"].ToString()); + } + + [Fact] + public async Task Should_Match_Path_With_Wildcard() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + int callCount = 0; + app.OnPublish("/default/*", async (payload) => + { + callCount++; + return new Dictionary { ["wildcard_matched"] = true }; + }); + + // Act + var result = await app.Resolve(_appSyncEvent, lambdaContext); + + // Assert + Assert.Equal(3, result.Events.Count); + Assert.Equal(3, callCount); + Assert.True((bool)result.Events[0].Payload["wildcard_matched"]); + } + + [Fact] + public async Task Should_Authorize_Subscription() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnSubscribe("/default/*", async (info) => true); + var subscribeEvent = new AppSyncResolverEvent + { + Info = new Information + { + Channel = new Channel { Path = "/default/channel" }, + Operation = AppsyncEventsOperation.Subscribe + } + }; + // Act + var result = await app.Resolve(subscribeEvent, lambdaContext); + + // Assert + Assert.True(result.Authorized); + } + + [Fact] + public async Task Should_Deny_Subscription() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnSubscribe("/default/*", async (info) => false); + var subscribeEvent = new AppSyncResolverEvent + { + Info = new Information + { + Channel = new Channel { Path = "/default/channel" }, + Operation = AppsyncEventsOperation.Subscribe + } + }; + // Act + var result = await app.Resolve(subscribeEvent, lambdaContext); + + // Assert + Assert.False(result.Authorized); + } + + [Fact] + public async Task Should_Deny_Subscription_On_Exception() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnSubscribe("/default/*", async (info) => + { + throw new Exception("Authorization error"); + }); + + var subscribeEvent = new AppSyncResolverEvent + { + Info = new Information + { + Channel = new Channel { Path = "/default/channel" }, + Operation = AppsyncEventsOperation.Subscribe + } + }; + + // Act + var result = await app.Resolve(subscribeEvent, lambdaContext); + + // Assert + Assert.False(result.Authorized); + } + + [Fact] + public async Task Should_Execute_Multiple_Matching_Handlers() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/default/channel", async (payload) => + { + return new Dictionary { ["handler"] = "first" }; + }); + + app.OnPublish("/default/*", async (payload) => + { + return new Dictionary { ["handler"] = "second" }; + }); + + // Act + var result = await app.Resolve(_appSyncEvent, lambdaContext); + + // Assert + Assert.Equal(6, result.Events.Count); + Assert.Equal("first", result.Events[0].Payload["handler"].ToString()); + Assert.Equal("first", result.Events[1].Payload["handler"].ToString()); + Assert.Equal("second", result.Events[4].Payload["handler"].ToString()); + Assert.Equal("second", result.Events[5].Payload["handler"].ToString()); + } + + [Fact] + public async Task Should_Respect_HandlerPathSpecificity() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/*", async (payload) => + { + return new Dictionary { ["handler"] = "least-specific" }; + }); + + app.OnPublish("/default/*", async (payload) => + { + return new Dictionary { ["handler"] = "more-specific" }; + }); + + app.OnPublish("/default/channel", async (payload) => + { + return new Dictionary { ["handler"] = "most-specific" }; + }); + + // Act + var result = await app.Resolve(_appSyncEvent, lambdaContext); + + // Assert - The most specific handler should be first + Assert.Equal(9, result.Events.Count); // 3 handlers x 3 events + Assert.Equal("most-specific", result.Events[0].Payload["handler"].ToString()); + Assert.Equal("more-specific", result.Events[3].Payload["handler"].ToString()); + Assert.Equal("least-specific", result.Events[6].Payload["handler"].ToString()); + } + + [Fact] + public async Task Should_Handle_Error_In_Aggregate_Mode() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/default/channel", async (evt, ctx) => + { + throw new InvalidOperationException("Aggregate error"); + }, aggregate: true); + + // Act + var result = await app.Resolve(_appSyncEvent, lambdaContext); + + // Assert + Assert.Single(result.Events); + Assert.NotNull(result.Events[0].Error); + Assert.Contains("Aggregate error", result.Events[0].Error); + } + + [Fact] + public async Task Should_Handle_TransformingPayload() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/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.Resolve(_appSyncEvent, lambdaContext); + + // Assert + 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() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + var unknownEvent = new AppSyncResolverEvent + { + Info = new Information + { + Channel = new Channel { Path = "/default/channel" }, + Operation = (AppsyncEventsOperation)999 // Unknown operation + } + }; + + // Act & Assert + await Assert.ThrowsAsync(() => + app.Resolve(unknownEvent, lambdaContext)); + } + + [Fact] + public async Task Should_Return_NonDictionary_Values_Wrapped_In_Data() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/default/channel", async (payload) => + { + // Return a non-dictionary value + return "string value"; + }); + + // Act + var result = await app.Resolve(_appSyncEvent, lambdaContext); + + // Assert + Assert.Equal(3, result.Events.Count); + Assert.Equal("string value", result.Events[0].Payload["data"].ToString()); + } + + [Fact] + public async Task 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", async (payload) => + { + handlerCalled = true; + return payload; + }); + + // Act + var result = await app.Resolve(_appSyncEvent, lambdaContext); + + // Assert - Should return original payload, handler not called + Assert.Equal(3, result.Events.Count); + Assert.Equal("data_1", result.Events[0].Payload["event_1"].ToString()); + Assert.False(handlerCalled); + } + + [Fact] + public async Task Should_Replace_Handler_When_RegisteringTwice() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/default/channel", async (payload) => + { + return new Dictionary { ["handler"] = "first" }; + }); + + app.OnPublish("/default/channel", async (payload) => + { + return new Dictionary { ["handler"] = "second" }; + }); + + // Act + var result = await app.Resolve(_appSyncEvent, lambdaContext); + + // Assert - Only second handler should be used + Assert.Equal(3, result.Events.Count); + Assert.Equal("second", result.Events[0].Payload["handler"].ToString()); + } + + [Fact] + public async Task Should_Maintain_EventIds_When_Processing() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/default/channel", async (payload) => + { + return new Dictionary { ["processed"] = true }; + }); + + // Act + var result = await app.Resolve(_appSyncEvent, lambdaContext); + + // Assert + 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); + } + +} \ 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..46676289 --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/RouteHandlerRegistryTests.cs @@ -0,0 +1,228 @@ +using System.Text.RegularExpressions; +using Amazon.Lambda.TestUtilities; +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/b/c/*", true)] + [InlineData("default/channel", false)] // Missing leading slash + [InlineData("/default/*/channel", false)] // Wildcard in middle + [InlineData("/default/**", false)] // Double wildcard + [InlineData("/*a", false)] // Invalid wildcard usage + [InlineData("", false)] // Empty + public void IsValidPath_ShouldValidateCorrectly(string path, bool expected) + { + // Act + var result = RouteHandlerRegistry.IsValidPath(path); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("/default/channel", @"^/default/channel$")] + [InlineData("/default/*", @"^/default/.*$")] + [InlineData("/*", @"^/.*$")] + [InlineData("/a/b+c", @"^/a/b\+c$")] // Test escaping special characters + public void PathToRegexString_ShouldConvertCorrectly(string path, string expected) + { + // Act + var result = RouteHandlerRegistry.PathToRegexString(path); + + // Assert + Assert.Equal(expected, result); + Assert.True(Regex.IsMatch(path.Replace("*", "anything"), result)); + } + + [Fact] + public void Register_ShouldNotAddInvalidPath() + { + // Arrange + var registry = new RouteHandlerRegistry(); + var called = false; + + // Act + registry.Register(new RouteHandlerOptions + { + Path = "/invalid/*/path", + Handler = (_, __) => + { + called = true; + return Task.FromResult(true); + } + }); + + var result = registry.Resolve("/invalid/something/path"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Register_ShouldReplaceExistingHandler() + { + // Arrange + var registry = new RouteHandlerRegistry(); + + // Act + registry.Register(new RouteHandlerOptions + { + Path = "/test/path", + Handler = (_, __) => Task.FromResult("first") + }); + + registry.Register(new RouteHandlerOptions + { + Path = "/test/path", + Handler = (_, __) => Task.FromResult("second") + }); + + var handler = registry.Resolve("/test/path"); + + // Assert + Assert.NotNull(handler); + var result = handler.Handler("test", new TestLambdaContext()).Result; + Assert.Equal("second", result); + } + + [Fact] + public void Resolve_ShouldReturnMostSpecificHandler() + { + // Arrange + var registry = new RouteHandlerRegistry(); + + registry.Register(new RouteHandlerOptions + { + Path = "/*", + Handler = (_, __) => Task.FromResult("wildcard") + }); + + registry.Register(new RouteHandlerOptions + { + Path = "/test/*", + Handler = (_, __) => Task.FromResult("test-wildcard") + }); + + registry.Register(new RouteHandlerOptions + { + Path = "/test/exact", + Handler = (_, __) => Task.FromResult("exact") + }); + + // Act & Assert + var handler1 = registry.Resolve("/test/exact"); + Assert.Equal("exact", handler1.Handler("test", new TestLambdaContext()).Result); + + var handler2 = registry.Resolve("/test/other"); + Assert.Equal("test-wildcard", handler2.Handler("test", new TestLambdaContext()).Result); + + var handler3 = registry.Resolve("/other/path"); + Assert.Equal("wildcard", handler3.Handler("test", new TestLambdaContext()).Result); + } + + [Fact] + public void Resolve_ShouldReturnNullWhenNoMatch() + { + // Arrange + var registry = new RouteHandlerRegistry(); + + registry.Register(new RouteHandlerOptions + { + Path = "/test/*", + Handler = (_, __) => Task.FromResult("test-wildcard") + }); + + // Act + var handler = registry.Resolve("/other/path"); + + // Assert + Assert.Null(handler); + } + + [Fact] + public void ResolveAll_ShouldReturnAllMatchingHandlersInOrder() + { + // Arrange + var registry = new RouteHandlerRegistry(); + + registry.Register(new RouteHandlerOptions + { + Path = "/*", + Handler = (_, __) => Task.FromResult("global") + }); + + registry.Register(new RouteHandlerOptions + { + Path = "/test/*", + Handler = (_, __) => Task.FromResult("test-wildcard") + }); + + registry.Register(new RouteHandlerOptions + { + Path = "/test/exact", + Handler = (_, __) => Task.FromResult("exact") + }); + + // Act + var handlers = registry.ResolveAll("/test/exact"); + + // Assert + Assert.Equal(3, handlers.Count); + Assert.Equal("exact", handlers[0].Handler("test", new TestLambdaContext()).Result); // Most specific + Assert.Equal("test-wildcard", handlers[1].Handler("test", new TestLambdaContext()).Result); + Assert.Equal("global", handlers[2].Handler("test", new TestLambdaContext()).Result); // Least specific + } + + [Fact] + public void Resolve_ShouldUseCacheForRepeatedPaths() + { + // Arrange + var registry = new RouteHandlerRegistry(3); // Small cache size + var callCount = 0; + + registry.Register(new RouteHandlerOptions + { + Path = "/path1", + Handler = (_, __) => { callCount++; return Task.FromResult(1); } + }); + + // Act + var handler1 = registry.Resolve("/path1"); + var result1 = handler1.Handler("test", new TestLambdaContext()).Result; + + var handler2 = registry.Resolve("/path1"); // Should use cache + var result2 = handler2.Handler("test", new TestLambdaContext()).Result; + + // Assert + Assert.Equal(1, result1); + Assert.Equal(1, result2); + Assert.Equal(2, callCount); // Handler called twice, but resolution only happened once + } + + [Fact] + public void Cache_ShouldEvictOldestItemsWhenFull() + { + // Arrange - Create a cache with size 2 + var cache = new LRUCache(2); + + // Act + cache.Add("key1", "value1"); + cache.Add("key2", "value2"); + cache.Add("key3", "value3"); // Should evict key1 + + // Assert + string value; + Assert.False(cache.TryGetValue("key1", out value)); // Should be evicted + Assert.True(cache.TryGetValue("key2", out value)); + Assert.Equal("value2", value); + Assert.True(cache.TryGetValue("key3", out value)); + Assert.Equal("value3", value); + } +} \ 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 From 9816720fdb2cc613b7bebd7a803e2f87fd619a99 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Wed, 16 Apr 2025 16:12:08 +0100 Subject: [PATCH 02/16] feat: enhance AppSyncEventsResolver with improved event handling and registration methods --- .../AppSyncEvents/AppSyncEventsResolver.cs | 282 ++++++------- .../AppSyncEvents/AppSyncResolverEvent.cs | 2 +- .../AppSyncEvents/Information.cs | 4 +- .../Internal/LRUCache.cs | 105 ++--- .../Internal/RouteHandlerRegistry.cs | 138 +++---- .../AppSyncEventsTests.cs | 369 ++++++++++++------ .../RouteHandlerRegistryTests.cs | 260 ++++++------ 7 files changed, 632 insertions(+), 528 deletions(-) diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs index 180f9b59..50c35e40 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs @@ -20,27 +20,69 @@ public AppSyncEventsResolver() _subscribeRoutes = new RouteHandlerRegistry(); } + /// + /// Registers a handler for publish events on a specific channel path. + /// Processes each event in the payload individually. + /// + public AppSyncEventsResolver OnPublish(string path, Func, Task> handler) + { + _publishRoutes.Register(new RouteHandlerOptions + { + Path = path, + Handler = (evt, ctx) => + { + var payload = evt.Events.FirstOrDefault()?.Payload; + return handler(payload ?? new Dictionary()); + }, + Aggregate = false + }); + return this; + } /// - /// Registers a handler for publish events on a specific channel path - /// Processes each event in the payload individually + /// Registers a handler for publish events on a specific channel path. + /// Processes each event in the payload individually. + /// Lambda context available /// - public AppSyncEventsResolver OnPublish(string path, Func, Task> handler, - bool aggregate = false) + public AppSyncEventsResolver OnPublish(string path, + Func, ILambdaContext, Task> handler) { - return OnPublish(path, async (evt, context) => + _publishRoutes.Register(new RouteHandlerOptions { - var tasks = evt.Events.Select(eventItem => - ProcessSingleEvent(eventItem.Payload, handler) - ).ToList(); + Path = path, + Handler = (evt, ctx) => + { + var payload = evt.Events.FirstOrDefault()?.Payload; + return handler(payload ?? new Dictionary(), ctx); + }, + Aggregate = false + }); + return this; + } - var results = await Task.WhenAll(tasks); - return results; - }, aggregate); + /// + /// Registers a handler for publish events on a specific channel path. + /// Processes all events in a single handler invocation. + /// + public AppSyncEventsResolver OnPublish(string path, Func> handler, + bool aggregate = true) + { + _publishRoutes.Register(new RouteHandlerOptions + { + Path = path, + Handler = (evt, ctx) => handler(evt), + Aggregate = aggregate + }); + return this; } + /// + /// Registers a handler for publish events on a specific channel path. + /// Processes all events in a single handler invocation. + /// Lambda context available + /// public AppSyncEventsResolver OnPublish(string path, - Func> handler, bool aggregate = false) + Func> handler, bool aggregate = true) { _publishRoutes.Register(new RouteHandlerOptions { @@ -48,24 +90,34 @@ public AppSyncEventsResolver OnPublish(string path, Handler = handler, Aggregate = aggregate }); - return this; } + /// + /// Registers a handler for subscription events on a specific channel path. + /// public AppSyncEventsResolver OnSubscribe(string path, Func> handler) { - return OnSubscribe(path, (evt, _) => handler(ExtractSubscriptionInfo(evt))); + _subscribeRoutes.Register(new RouteHandlerOptions + { + Path = path, + Handler = async (evt, ctx) => await handler(ExtractSubscriptionInfo(evt)), + Aggregate = true + }); + return this; } - public AppSyncEventsResolver OnSubscribe(string path, - Func> handler) + /// + /// Registers a handler for subscription events on a specific channel path with Lambda context. + /// + public AppSyncEventsResolver OnSubscribe(string path, Func> handler) { _subscribeRoutes.Register(new RouteHandlerOptions { Path = path, - Handler = handler + Handler = async (evt, ctx) => await handler(ExtractSubscriptionInfo(evt), ctx), + Aggregate = true }); - return this; } @@ -88,95 +140,104 @@ private async Task HandlePublishEvent(AppSyncReso ILambdaContext context) { var channelPath = appsyncEvent.Info.Channel.Path; - var matchingHandlers = _publishRoutes.ResolveAll(channelPath); + var handlerOptions = _publishRoutes.ResolveFirst(channelPath); - if (matchingHandlers.Count == 0) + if (handlerOptions == null) { - return new AppSyncResolverEventsResponse - { - Events = appsyncEvent.Events.Select(e => - new AppSyncResolverEventsResult - { - Id = e.id, - Payload = e.Payload - }).ToList() - }; + // Return unchanged events if no handler found + var events = appsyncEvent.Events + .Select(e => new AppSyncResolverEventsResult + { + Id = e.Id, + Payload = e.Payload + }) + .ToList(); + return new AppSyncResolverEventsResponse { Events = events }; } var results = new List(); - // Process each matching handler - foreach (var handlerOptions in matchingHandlers) + if (handlerOptions.Aggregate) { try { - var handler = handlerOptions.Handler; - var aggregate = handlerOptions.Aggregate; - - // Call handler once per registered handler - var handlerResult = await handler(appsyncEvent, context); + // Process entire event in one call + var handlerResult = await handlerOptions.Handler(appsyncEvent, context); - if (aggregate) - { - // For aggregate mode, return a single result - string error; - var payload = ConvertToPayload(handlerResult, out error); - results.Add(new AppSyncResolverEventsResult - { - Id = Guid.NewGuid().ToString(), - Payload = payload, - Error = error - }); - } - else if (handlerResult is IEnumerable resultArray) + // Check if the handler returned a collection of events + if (handlerResult is Dictionary dict && + dict.TryGetValue("events", out var eventsObj) && + eventsObj is IEnumerable eventsList) { - // For non-aggregate mode with array result - var eventItems = appsyncEvent.Events.ToArray(); - var i = 0; - - foreach (var item in resultArray) + // Process each event in the collection + foreach (var eventObj in eventsList) { - var id = i < eventItems.Length ? eventItems[i].id : Guid.NewGuid().ToString(); - - // Convert payload and check for errors - string errorMessage; - var payload = ConvertToPayload(item, out errorMessage); - - if (errorMessage != null) + if (eventObj is Dictionary eventDict) { - results.Add(new AppSyncResolverEventsResult + string eventId = null; + if (eventDict.TryGetValue("id", out var idObj) && idObj != null) { - Id = id, - Error = errorMessage - }); - } - else - { - results.Add(new AppSyncResolverEventsResult + eventId = idObj.ToString(); + } + + if (eventDict.TryGetValue("error", out var errorObj) && errorObj != null) + { + // This is an error result + results.Add(new AppSyncResolverEventsResult + { + Id = eventId, + Error = errorObj.ToString() + }); + } + else { - Id = id, - Payload = payload - }); + // Remove id from payload if present + var payload = new Dictionary(eventDict); + if (payload.ContainsKey("id")) payload.Remove("id"); + + results.Add(new AppSyncResolverEventsResult + { + Id = eventId, + Payload = payload + }); + } } - - i++; } } - else + } + catch (Exception ex) + { + results.Add(FormatErrorResponse(ex, null)); + } + } + else + { + // Process each event individually + foreach (var eventItem in appsyncEvent.Events) + { + try { - string error; - var payload = ConvertToPayload(handlerResult, out error); + // Create a copy of the event with just this single event + var singleEventCopy = new AppSyncResolverEvent + { + Info = appsyncEvent.Info, + Events = [eventItem] + }; + + var handlerResult = await handlerOptions.Handler(singleEventCopy, context); + var payload = ConvertToPayload(handlerResult, out var error); + results.Add(new AppSyncResolverEventsResult { - Id = appsyncEvent.Events.FirstOrDefault()?.id ?? Guid.NewGuid().ToString(), + Id = eventItem.Id, Payload = payload, Error = error }); } - } - catch (Exception ex) - { - results.Add(FormatErrorResponse(ex, Guid.NewGuid().ToString())); + catch (Exception ex) + { + results.Add(FormatErrorResponse(ex, eventItem.Id)); + } } } @@ -187,27 +248,17 @@ private async Task HandleSubscribeEvent(AppSyncRe ILambdaContext context) { var channelPath = appsyncEvent.Info.Channel.Path; - var matchingHandlers = _subscribeRoutes.ResolveAll(channelPath); + var handlerOptions = _subscribeRoutes.ResolveFirst(channelPath); - if (matchingHandlers.Count == 0) + if (handlerOptions == null) { return new AppSyncResolverEventsResponse { Authorized = false }; } try { - foreach (var handlerOptions in matchingHandlers) - { - var result = await handlerOptions.Handler(appsyncEvent, context); - - // If handler returns false, deny subscription - if (!result) - { - return new AppSyncResolverEventsResponse { Authorized = false }; - } - } - - return new AppSyncResolverEventsResponse { Authorized = true }; + var result = await handlerOptions.Handler(appsyncEvent, context); + return new AppSyncResolverEventsResponse { Authorized = (bool)result }; } catch (Exception ex) { @@ -216,22 +267,6 @@ private async Task HandleSubscribeEvent(AppSyncRe } } - -// Helper to process a single event and handle exceptions - private async Task ProcessSingleEvent(Dictionary payload, - Func, Task> handler) - { - try - { - return await handler(payload); - } - catch (Exception ex) - { - return new Dictionary { ["error"] = ex.Message }; - } - } - - private Information ExtractSubscriptionInfo(AppSyncResolverEvent appsyncEvent) { return new Information @@ -267,32 +302,11 @@ private AppSyncResolverEventsResult FormatErrorResponse(Exception ex, string id) { return new AppSyncResolverEventsResult { - Id = id, + Id = id, // This will be the original event ID or null Error = $"{ex.GetType().Name} - {ex.Message}" }; } - private List FindMatchingPaths(IEnumerable registeredPaths, string channelPath) - { - return registeredPaths.Where(pattern => IsMatch(pattern, channelPath)).ToList(); - } - - private bool IsMatch(string pattern, string path) - { - if (pattern == path) - { - return true; - } - - // Convert wildcards to regex patterns - var regexPattern = "^" + Regex.Escape(pattern) - .Replace("\\*\\*", ".*") // ** matches any segments - .Replace("\\*", "[^/]*") // * matches anything within a segment - + "$"; - - return Regex.IsMatch(path, regexPattern); - } - private bool IsPublishEvent(AppSyncResolverEvent appsyncEvent) { return appsyncEvent.Info.Operation == AppsyncEventsOperation.Publish; diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEvent.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEvent.cs index 38e2a53d..2a17dd99 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEvent.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEvent.cs @@ -56,5 +56,5 @@ public class AppSyncResolverEvent public object[] OutErrors { get; set; } - public Events[] Events { get; set; } + public Event[] Events { 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 index c451a0ab..317261d9 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Information.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Information.cs @@ -52,10 +52,10 @@ public enum AppsyncEventsOperation Publish } -public class Events +public class Event { public Dictionary Payload { get; set; } - public string id { get; set; } + public string Id { get; set; } } diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs index 59b9b062..465214da 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs @@ -5,68 +5,41 @@ namespace AWS.Lambda.Powertools.EventHandler.Internal; /// /// Basic LRU cache implementation /// -internal class LRUCache where TKey : notnull +/// +/// Simple LRU cache implementation for caching route resolutions +/// +internal class LRUCache { private readonly int _capacity; - private readonly ConcurrentDictionary> _cache; - private readonly LinkedList _lruList; - private readonly object _lock = new(); + private readonly Dictionary> _cache; + private readonly LinkedList _lruList; - /// - /// Initialize LRU cache with specified capacity - /// - public LRUCache(int capacity) + internal class CacheItem { - _capacity = capacity; - _cache = new ConcurrentDictionary>(); - _lruList = new LinkedList(); - } + public TKey Key { get; } + public TValue Value { get; } - /// - /// Add or update a key-value pair in the cache - /// - public void Add(TKey key, TValue value) - { - lock (_lock) + public CacheItem(TKey key, TValue value) { - if (_cache.TryGetValue(key, out LinkedListNode node)) - { - // Move existing item to front of list - _lruList.Remove(node); - node.Value.Value = value; - _lruList.AddFirst(node); - } - else - { - // Trim cache if at capacity - if (_cache.Count >= _capacity && _lruList.Last != null) - { - _cache.TryRemove(_lruList.Last.Value.Key, out _); - _lruList.RemoveLast(); - } - - // Add new item to front - var cacheItem = new LRUCacheItem { Key = key, Value = value }; - var newNode = new LinkedListNode(cacheItem); - _lruList.AddFirst(newNode); - _cache[key] = newNode; - } + Key = key; + Value = value; } } - /// - /// Try to get a value from the cache - /// - public bool TryGetValue(TKey key, out TValue 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)) { - lock (_lock) - { - // Move accessed item to front of list - _lruList.Remove(node); - _lruList.AddFirst(node); - } + // Move to the front of the list (most recently used) + _lruList.Remove(node); + _lruList.AddFirst(node); value = node.Value.Value; return true; } @@ -75,27 +48,29 @@ public bool TryGetValue(TKey key, out TValue value) return false; } - /// - /// Get or create a value in the cache - /// - public TValue GetOrAdd(TKey key, Func valueFactory) + public void Set(TKey key, TValue value) { - if (TryGetValue(key, out var value)) + if (_cache.TryGetValue(key, out var existingNode)) + { + _lruList.Remove(existingNode); + _cache.Remove(key); + } + else if (_cache.Count >= _capacity) { - return value; + // Remove least recently used item + var lastNode = _lruList.Last; + _lruList.RemoveLast(); + _cache.Remove(lastNode.Value.Key); } - var newValue = valueFactory(key); - Add(key, newValue); - return newValue; + var newNode = new LinkedListNode(new CacheItem(key, value)); + _lruList.AddFirst(newNode); + _cache[key] = newNode; } - /// - /// Helper class for LRU cache items - /// - private class LRUCacheItem + public void Clear() { - public TKey Key { get; set; } - public TValue Value { get; set; } + _cache.Clear(); + _lruList.Clear(); } } \ 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 index eeb5cdbd..99438b57 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerRegistry.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerRegistry.cs @@ -9,7 +9,7 @@ namespace AWS.Lambda.Powertools.EventHandler.Internal; internal class RouteHandlerRegistry { /// - /// Dictionary of registered handlers, keyed by regex pattern + /// Dictionary of registered handlers /// private readonly Dictionary> _resolvers = new(); @@ -19,7 +19,7 @@ internal class RouteHandlerRegistry private readonly LRUCache> _resolverCache; /// - /// Set to track already logged warnings to prevent duplicates + /// Set to track already logged warnings /// private readonly HashSet _warnedPaths = new(); @@ -38,25 +38,16 @@ public RouteHandlerRegistry(int cacheSize = 100) /// Options for the route handler public void Register(RouteHandlerOptions options) { - LogDebug($"Registering route handler for path \"{options.Path}\" with aggregate \"{options.Aggregate}\""); - if (!IsValidPath(options.Path)) { LogWarning($"The path \"{options.Path}\" is not valid and will be skipped. " + - "A path should always have a namespace starting with \"/\". A path can have multiple namespaces, " + - "all separated by \"/\". Wildcards are allowed only at the end of the path."); + "Wildcards are allowed only at the end of the path."); return; } - string regex = PathToRegexString(options.Path); - - if (_resolvers.ContainsKey(regex)) - { - LogWarning($"A route handler for path \"{options.Path}\" is already registered. " + - "The previous handler will be replaced."); - } - - _resolvers[regex] = options; + // Clear cache when registering new handlers + _resolverCache.Clear(); + _resolvers[options.Path] = options; } /// @@ -64,101 +55,84 @@ public void Register(RouteHandlerOptions options) /// /// The path to match against registered routes /// Most specific matching handler or null if no match - public RouteHandlerOptions Resolve(string path) + public RouteHandlerOptions? ResolveFirst(string path) { - // First check cache - if (_resolverCache.TryGetValue(path, out var cachedHandler)) + if (_resolverCache.TryGet(path, out var cachedHandler)) { return cachedHandler; } - LogDebug($"Resolving handler for path \"{path}\""); - - RouteHandlerOptions mostSpecificHandler = null; - int mostSpecificRouteLength = 0; - - foreach (var (pattern, handlerOptions) in _resolvers) + // First try for exact match + if (_resolvers.TryGetValue(path, out var exactMatch)) { - if (Regex.IsMatch(path, pattern)) - { - // Calculate specificity (length of path minus wildcard) - int specificityLength = handlerOptions.Path.Length - - (handlerOptions.Path.EndsWith("*") ? 1 : 0); - - if (specificityLength > mostSpecificRouteLength) - { - mostSpecificRouteLength = specificityLength; - mostSpecificHandler = handlerOptions; - _resolverCache.Add(path, handlerOptions); - } - } + _resolverCache.Set(path, exactMatch); + return exactMatch; } - // Log warning if no handler found - if (mostSpecificHandler == null && !_warnedPaths.Contains(path)) - { - LogWarning($"No route handler found for path \"{path}\"."); - _warnedPaths.Add(path); - } + // Then try wildcard matches, sorted by specificity (most segments first) + var wildcardMatches = _resolvers.Keys + .Where(pattern => IsWildcardMatch(pattern, path)) + .OrderByDescending(pattern => pattern.Count(c => c == '/')) + .ThenByDescending(pattern => pattern.Length); - return mostSpecificHandler; - } + var bestMatch = wildcardMatches.FirstOrDefault(); - /// - /// Find all handlers that match the given path. - /// Returns them sorted by specificity (most specific first). - /// - /// Path to match - /// List of matching handlers in order of specificity - public List> ResolveAll(string path) - { - var matches = new List<(RouteHandlerOptions Handler, int Specificity)>(); - - foreach (var (pattern, handlerOptions) in _resolvers) + if (bestMatch != null) { - if (Regex.IsMatch(path, pattern)) - { - int specificityLength = handlerOptions.Path.Length - - (handlerOptions.Path.EndsWith("*") ? 1 : 0); - matches.Add((handlerOptions, specificityLength)); - } + var handler = _resolvers[bestMatch]; + _resolverCache.Set(path, handler); + return handler; } - return matches - .OrderByDescending(x => x.Specificity) - .Select(x => x.Handler) - .ToList(); + return null; } /// /// Check if a path pattern is valid according to routing rules. /// - /// Path to validate - /// Whether the path is valid - public static bool IsValidPath(string path) + private static bool IsValidPath(string path) { - if (path == "/*") return true; - return Regex.IsMatch(path, @"^\/([^\/\*]+)(\/[^\/\*]+)*(\/\*)?$"); + if (string.IsNullOrWhiteSpace(path) || !path.StartsWith("/")) + return false; + + // Check for invalid wildcard usage + return !path.Contains("*/"); } /// - /// Converts a path pattern to a regex string for matching. + /// Check if a wildcard pattern matches the given path /// - /// Path pattern to convert - /// Regular expression string - public static string PathToRegexString(string path) + private bool IsWildcardMatch(string pattern, string path) { - string escapedPath = Regex.Escape(path); - return $"^{escapedPath.Replace("\\*", ".*")}$"; - } + if (!pattern.Contains('*')) + return pattern == path; - private void LogDebug(string message) - { - Console.WriteLine(message); + 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) { - Console.WriteLine($"Warning: {message}"); + if (!_warnedPaths.Contains(message)) + { + _warnedPaths.Add(message); + Console.WriteLine($"Warning: {message}"); + } } } \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs index 2e4ce474..8477f20d 100644 --- a/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs @@ -6,7 +6,6 @@ namespace AWS.Lambda.Powertools.EventHandler.Tests; - public class AppSyncEventsTests { private readonly AppSyncResolverEvent? _appSyncEvent; @@ -21,7 +20,7 @@ public AppSyncEventsTests() Converters = { new JsonStringEnumConverter() } }); } - + [Fact] public async Task Should_Return_Unchanged_Payload_No_Handlers() { @@ -30,7 +29,7 @@ public async Task Should_Return_Unchanged_Payload_No_Handlers() var app = new AppSyncEventsResolver(); // Act - var result = + var result = await app.Resolve(_appSyncEvent, lambdaContext); // Assert @@ -50,14 +49,14 @@ public async Task Should_Return_Unchanged_Payload() var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - app.OnPublish("/default/channel", async (payload) => + app.OnPublish("/default/channel", async (Dictionary payload) => { // Handle channel1 events return payload; }); - + // Act - var result = + var result = await app.Resolve(_appSyncEvent, lambdaContext); // Assert @@ -69,7 +68,7 @@ public async Task Should_Return_Unchanged_Payload() 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() { @@ -84,6 +83,7 @@ public async Task Should_Handle_Error_In_Event_Processing() { throw new InvalidOperationException("Test error"); } + return payload; }); @@ -101,40 +101,13 @@ public async Task Should_Handle_Error_In_Event_Processing() Assert.Equal("data_3", result.Events[2].Payload["event_3"].ToString()); } - [Fact] - public async Task Should_Process_Events_In_Aggregate() - { - // Arrange - var lambdaContext = new TestLambdaContext(); - var app = new AppSyncEventsResolver(); - - app.OnPublish("/default/channel", async (evt, ctx) => - { - // Create aggregate result from all events - return new Dictionary - { - ["combined"] = true, - ["count"] = evt.Events.Count(), - ["ids"] = string.Join(",", evt.Events.Select(e => e.id)) - }; - }, aggregate: true); - - // Act - var result = await app.Resolve(_appSyncEvent, lambdaContext); - - // Assert - Assert.Single(result.Events); - Assert.Equal("3", result.Events[0].Payload["count"].ToString()); - Assert.Equal("1,2,3", result.Events[0].Payload["ids"].ToString()); - } - [Fact] public async Task Should_Match_Path_With_Wildcard() { // Arrange var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - + int callCount = 0; app.OnPublish("/default/*", async (payload) => { @@ -204,11 +177,8 @@ public async Task Should_Deny_Subscription_On_Exception() var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - app.OnSubscribe("/default/*", async (info) => - { - throw new Exception("Authorization error"); - }); - + app.OnSubscribe("/default/*", async (info) => { throw new Exception("Authorization error"); }); + var subscribeEvent = new AppSyncResolverEvent { Info = new Information @@ -225,66 +195,6 @@ public async Task Should_Deny_Subscription_On_Exception() Assert.False(result.Authorized); } - [Fact] - public async Task Should_Execute_Multiple_Matching_Handlers() - { - // Arrange - var lambdaContext = new TestLambdaContext(); - var app = new AppSyncEventsResolver(); - - app.OnPublish("/default/channel", async (payload) => - { - return new Dictionary { ["handler"] = "first" }; - }); - - app.OnPublish("/default/*", async (payload) => - { - return new Dictionary { ["handler"] = "second" }; - }); - - // Act - var result = await app.Resolve(_appSyncEvent, lambdaContext); - - // Assert - Assert.Equal(6, result.Events.Count); - Assert.Equal("first", result.Events[0].Payload["handler"].ToString()); - Assert.Equal("first", result.Events[1].Payload["handler"].ToString()); - Assert.Equal("second", result.Events[4].Payload["handler"].ToString()); - Assert.Equal("second", result.Events[5].Payload["handler"].ToString()); - } - - [Fact] - public async Task Should_Respect_HandlerPathSpecificity() - { - // Arrange - var lambdaContext = new TestLambdaContext(); - var app = new AppSyncEventsResolver(); - - app.OnPublish("/*", async (payload) => - { - return new Dictionary { ["handler"] = "least-specific" }; - }); - - app.OnPublish("/default/*", async (payload) => - { - return new Dictionary { ["handler"] = "more-specific" }; - }); - - app.OnPublish("/default/channel", async (payload) => - { - return new Dictionary { ["handler"] = "most-specific" }; - }); - - // Act - var result = await app.Resolve(_appSyncEvent, lambdaContext); - - // Assert - The most specific handler should be first - Assert.Equal(9, result.Events.Count); // 3 handlers x 3 events - Assert.Equal("most-specific", result.Events[0].Payload["handler"].ToString()); - Assert.Equal("more-specific", result.Events[3].Payload["handler"].ToString()); - Assert.Equal("least-specific", result.Events[6].Payload["handler"].ToString()); - } - [Fact] public async Task Should_Handle_Error_In_Aggregate_Mode() { @@ -292,10 +202,8 @@ public async Task Should_Handle_Error_In_Aggregate_Mode() var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - app.OnPublish("/default/channel", async (evt, ctx) => - { - throw new InvalidOperationException("Aggregate error"); - }, aggregate: true); + app.OnPublish("/default/channel", + async (evt, ctx) => { throw new InvalidOperationException("Aggregate error"); }, aggregate: true); // Act var result = await app.Resolve(_appSyncEvent, lambdaContext); @@ -321,6 +229,7 @@ public async Task Should_Handle_TransformingPayload() { transformedPayload[$"transformed_{key}"] = $"transformed_{payload[key]}"; } + return transformedPayload; }); @@ -406,15 +315,11 @@ public async Task Should_Replace_Handler_When_RegisteringTwice() var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - app.OnPublish("/default/channel", async (payload) => - { - return new Dictionary { ["handler"] = "first" }; - }); + app.OnPublish("/default/channel", + async (payload) => { return new Dictionary { ["handler"] = "first" }; }); - app.OnPublish("/default/channel", async (payload) => - { - return new Dictionary { ["handler"] = "second" }; - }); + app.OnPublish("/default/channel", + async (payload) => { return new Dictionary { ["handler"] = "second" }; }); // Act var result = await app.Resolve(_appSyncEvent, lambdaContext); @@ -431,9 +336,97 @@ public async Task Should_Maintain_EventIds_When_Processing() var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); + app.OnPublish("/default/channel", + async (payload) => { return new Dictionary { ["processed"] = true }; }); + + // Act + var result = await app.Resolve(_appSyncEvent, lambdaContext); + + // Assert + 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.OnPublish("/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 Dictionary + { + ["id"] = eventItem.Id, + ["error"] = "Intentional error for event 2" + }); + } + else + { + // Process normally + results.Add(new Dictionary + { + ["id"] = eventItem.Id, + ["processed"] = true, + ["originalData"] = eventItem.Payload + }); + } + } + catch (Exception ex) + { + results.Add(new Dictionary + { + ["id"] = eventItem.Id, + ["error"] = $"{ex.GetType().Name} - {ex.Message}" + }); + } + } + + return new Dictionary { ["events"] = results }; + }, aggregate: true); + + // Act + var result = await app.Resolve(_appSyncEvent, lambdaContext); + + // Assert + Assert.Equal(3, result.Events.Count); + Assert.Equal("1", result.Events[0].Id); + Assert.True((bool)result.Events[0].Payload["processed"]); + 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"]); + } + + [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.OnPublish("/default/channel", async (payload) => { - return new Dictionary { ["processed"] = true }; + 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 @@ -442,8 +435,158 @@ public async Task Should_Maintain_EventIds_When_Processing() // 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.OnPublish("/default/channel", async (payload) => + { + firstHandlerCalls++; + return new Dictionary { ["handler"] = "first" }; + }); + + app.OnPublish("/default/*", async (payload) => + { + secondHandlerCalls++; + return new Dictionary { ["handler"] = "second" }; + }); + + // Act + var result = await app.Resolve(_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 AppSyncResolverEvent + { + Info = new Information + { + Channel = new Channel { Path = "/default/channel" }, + Operation = AppsyncEventsOperation.Publish + }, + Events = + [ + new Event + { + Id = "1", + Payload = new Dictionary + { + ["event_1"] = "data_1", + ["event_1a"] = "data_1a" + } + } + ] + }; + + app.OnPublish("/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.Resolve(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.OnPublish("/*", async (payload) => + new Dictionary { ["handler"] = "least-specific" }); + + app.OnPublish("/default/*", async (payload) => + new Dictionary { ["handler"] = "more-specific" }); + + app.OnPublish("/default/channel", async (payload) => + new Dictionary { ["handler"] = "most-specific" }); + + // Act + var result = await app.Resolve(_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 AppSyncResolverEvent + { + Info = new Information + { + Channel = new Channel { Path = "/default/specific/path" }, + Operation = AppsyncEventsOperation.Publish + }, + Events = + [ + new Event + { + Id = "1", + Payload = new Dictionary { ["key"] = "value" } + } + ] + }; + + app.OnPublish("/default/*", async (payload) => + new Dictionary { ["handler"] = "wildcard-handler" }); + + // Act + var result = await app.Resolve(fallbackEvent, lambdaContext); + + // Assert + Assert.Single(result.Events); + Assert.Equal("wildcard-handler", result.Events[0].Payload["handler"].ToString()); +} } \ 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 index 46676289..06181944 100644 --- a/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/RouteHandlerRegistryTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/RouteHandlerRegistryTests.cs @@ -1,5 +1,3 @@ -using System.Text.RegularExpressions; -using Amazon.Lambda.TestUtilities; using AWS.Lambda.Powertools.EventHandler.Internal; namespace AWS.Lambda.Powertools.EventHandler.Tests; @@ -11,218 +9,218 @@ public class RouteHandlerRegistryTests [InlineData("/default/*", true)] [InlineData("/*", true)] [InlineData("/a/b/c", true)] - [InlineData("/a/b/c/*", true)] - [InlineData("default/channel", false)] // Missing leading slash - [InlineData("/default/*/channel", false)] // Wildcard in middle - [InlineData("/default/**", false)] // Double wildcard - [InlineData("/*a", false)] // Invalid wildcard usage - [InlineData("", false)] // Empty + [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) { - // Act - var result = RouteHandlerRegistry.IsValidPath(path); + // 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); - // Assert - Assert.Equal(expected, result); - } - - [Theory] - [InlineData("/default/channel", @"^/default/channel$")] - [InlineData("/default/*", @"^/default/.*$")] - [InlineData("/*", @"^/.*$")] - [InlineData("/a/b+c", @"^/a/b\+c$")] // Test escaping special characters - public void PathToRegexString_ShouldConvertCorrectly(string path, string expected) - { // Act - var result = RouteHandlerRegistry.PathToRegexString(path); + var result = (bool)isValidPathMethod.Invoke(null, new object[] { path }); // Assert Assert.Equal(expected, result); - Assert.True(Regex.IsMatch(path.Replace("*", "anything"), result)); } - + [Fact] public void Register_ShouldNotAddInvalidPath() { // Arrange - var registry = new RouteHandlerRegistry(); - var called = false; + var registry = new RouteHandlerRegistry(); // Act - registry.Register(new RouteHandlerOptions - { - Path = "/invalid/*/path", - Handler = (_, __) => - { - called = true; - return Task.FromResult(true); - } + registry.Register(new RouteHandlerOptions + { + Path = "/invalid/*/path", // Invalid path with wildcard in the middle + Handler = (_, _) => Task.FromResult(null) }); - var result = registry.Resolve("/invalid/something/path"); - - // Assert - Assert.Null(result); + // 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(); + var registry = new RouteHandlerRegistry(); + int firstHandlerCalled = 0; + int secondHandlerCalled = 0; // Act - registry.Register(new RouteHandlerOptions + registry.Register(new RouteHandlerOptions { Path = "/test/path", - Handler = (_, __) => Task.FromResult("first") + Handler = (_, _) => { + firstHandlerCalled++; + return Task.FromResult("first"); + } }); - registry.Register(new RouteHandlerOptions + registry.Register(new RouteHandlerOptions { - Path = "/test/path", - Handler = (_, __) => Task.FromResult("second") + Path = "/test/path", // Same path, should replace first handler + Handler = (_, _) => { + secondHandlerCalled++; + return Task.FromResult("second"); + } }); - var handler = registry.Resolve("/test/path"); - // Assert + var handler = registry.ResolveFirst("/test/path"); Assert.NotNull(handler); - var result = handler.Handler("test", new TestLambdaContext()).Result; + var result = handler.Handler(null, null).Result; Assert.Equal("second", result); + Assert.Equal(0, firstHandlerCalled); + Assert.Equal(1, secondHandlerCalled); } - + [Fact] - public void Resolve_ShouldReturnMostSpecificHandler() + public async Task ResolveFirst_ShouldReturnMostSpecificHandler() { // Arrange - var registry = new RouteHandlerRegistry(); + var registry = new RouteHandlerRegistry(); - registry.Register(new RouteHandlerOptions + registry.Register(new RouteHandlerOptions { Path = "/*", - Handler = (_, __) => Task.FromResult("wildcard") + Handler = (_, _) => Task.FromResult("least-specific") }); - registry.Register(new RouteHandlerOptions + registry.Register(new RouteHandlerOptions { - Path = "/test/*", - Handler = (_, __) => Task.FromResult("test-wildcard") + Path = "/default/*", + Handler = (_, _) => Task.FromResult("more-specific") }); - registry.Register(new RouteHandlerOptions + registry.Register(new RouteHandlerOptions { - Path = "/test/exact", - Handler = (_, __) => Task.FromResult("exact") + Path = "/default/channel", + Handler = (_, _) => Task.FromResult("most-specific") }); - // Act & Assert - var handler1 = registry.Resolve("/test/exact"); - Assert.Equal("exact", handler1.Handler("test", new TestLambdaContext()).Result); + // Act - Test various paths + var exactMatch = registry.ResolveFirst("/default/channel"); + var wildcardMatch = registry.ResolveFirst("/default/something"); + var rootMatch = registry.ResolveFirst("/something"); - var handler2 = registry.Resolve("/test/other"); - Assert.Equal("test-wildcard", handler2.Handler("test", new TestLambdaContext()).Result); + // Assert + Assert.NotNull(exactMatch); + Assert.Equal("most-specific", await exactMatch.Handler(null, null)); - var handler3 = registry.Resolve("/other/path"); - Assert.Equal("wildcard", handler3.Handler("test", new TestLambdaContext()).Result); + 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 Resolve_ShouldReturnNullWhenNoMatch() + public void ResolveFirst_ShouldReturnNullWhenNoMatch() { // Arrange - var registry = new RouteHandlerRegistry(); + var registry = new RouteHandlerRegistry(); - registry.Register(new RouteHandlerOptions + registry.Register(new RouteHandlerOptions { - Path = "/test/*", - Handler = (_, __) => Task.FromResult("test-wildcard") + Path = "/default/*", + Handler = (_, _) => Task.FromResult("test") }); // Act - var handler = registry.Resolve("/other/path"); + var result = registry.ResolveFirst("/other/path"); // Assert - Assert.Null(handler); + Assert.Null(result); } - + [Fact] - public void ResolveAll_ShouldReturnAllMatchingHandlersInOrder() + public void ResolveFirst_ShouldUseCacheForRepeatedPaths() { // Arrange - var registry = new RouteHandlerRegistry(); + var registry = new RouteHandlerRegistry(); + int handlerCallCount = 0; - registry.Register(new RouteHandlerOptions - { - Path = "/*", - Handler = (_, __) => Task.FromResult("global") - }); - - registry.Register(new RouteHandlerOptions + registry.Register(new RouteHandlerOptions { Path = "/test/*", - Handler = (_, __) => Task.FromResult("test-wildcard") + Handler = (_, _) => { + handlerCallCount++; + return Task.FromResult("cached"); + } }); - registry.Register(new RouteHandlerOptions - { - Path = "/test/exact", - Handler = (_, __) => Task.FromResult("exact") - }); + // Act - Resolve the same path multiple times + var first = registry.ResolveFirst("/test/path"); + var firstResult = first.Handler(null, null).Result; - // Act - var handlers = registry.ResolveAll("/test/exact"); + // Should use cached result + var second = registry.ResolveFirst("/test/path"); + var secondResult = second.Handler(null, null).Result; // Assert - Assert.Equal(3, handlers.Count); - Assert.Equal("exact", handlers[0].Handler("test", new TestLambdaContext()).Result); // Most specific - Assert.Equal("test-wildcard", handlers[1].Handler("test", new TestLambdaContext()).Result); - Assert.Equal("global", handlers[2].Handler("test", new TestLambdaContext()).Result); // Least specific + 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 Resolve_ShouldUseCacheForRepeatedPaths() + public void LRUCache_ShouldEvictOldestItemsWhenFull() { - // Arrange - var registry = new RouteHandlerRegistry(3); // Small cache size - var callCount = 0; - - registry.Register(new RouteHandlerOptions - { - Path = "/path1", - Handler = (_, __) => { callCount++; return Task.FromResult(1); } - }); + // Arrange - Create a cache with size 2 + var cache = new LRUCache(2); // Act - var handler1 = registry.Resolve("/path1"); - var result1 = handler1.Handler("test", new TestLambdaContext()).Result; - - var handler2 = registry.Resolve("/path1"); // Should use cache - var result2 = handler2.Handler("test", new TestLambdaContext()).Result; + cache.Set("key1", "value1"); + cache.Set("key2", "value2"); + cache.Set("key3", "value3"); // Should evict key1 // Assert - Assert.Equal(1, result1); - Assert.Equal(1, result2); - Assert.Equal(2, callCount); // Handler called twice, but resolution only happened once + 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 Cache_ShouldEvictOldestItemsWhenFull() + public void IsWildcardMatch_ShouldMatchPathsCorrectly() { - // Arrange - Create a cache with size 2 - var cache = new LRUCache(2); - - // Act - cache.Add("key1", "value1"); - cache.Add("key2", "value2"); - cache.Add("key3", "value3"); // Should evict key1 + // Arrange + var registry = new RouteHandlerRegistry(); + var isWildcardMatchMethod = typeof(RouteHandlerRegistry) + .GetMethod("IsWildcardMatch", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - // Assert - string value; - Assert.False(cache.TryGetValue("key1", out value)); // Should be evicted - Assert.True(cache.TryGetValue("key2", out value)); - Assert.Equal("value2", value); - Assert.True(cache.TryGetValue("key3", out value)); - Assert.Equal("value3", value); + // 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 From b3648819b28a44999450f40dba444c7f6cd2ff85 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Wed, 16 Apr 2025 18:17:51 +0100 Subject: [PATCH 03/16] feat: refactor AppSync event handling with nullable properties and improved class structure. Unauthorized exception and null subscribe responses --- .../AppSyncEvents/AppSyncAuthorizerEvent.cs | 26 -- .../AppSyncEvents/AppSyncAuthorizerResult.cs | 34 -- .../AppSyncEvents/AppSyncCognitoIdentity.cs | 14 +- ...esolverEventsResult.cs => AppSyncEvent.cs} | 8 +- ...ResolverEvent.cs => AppSyncEventsEvent.cs} | 30 +- .../AppSyncEvents/AppSyncEventsOperation.cs | 17 + .../AppSyncEvents/AppSyncEventsResolver.cs | 111 +++--- .../AppSyncEvents/AppSyncEventsResponse.cs | 22 + .../AppSyncEvents/AppSyncIamIdentity.cs | 16 +- .../AppSyncEvents/AppSyncLambdaIdentity.cs | 2 +- .../AppSyncEvents/AppSyncRequestContext.cs | 12 +- .../AppSyncResolverEventsResponse.cs | 11 - .../AppSyncEvents/Channel.cs | 4 +- .../AppSyncEvents/ChannelNamespace.cs | 5 +- .../AppSyncEvents/Information.cs | 55 +-- .../Internal/LRUCache.cs | 6 +- .../AppSyncEventsTests.cs | 376 +++++++++++------- 17 files changed, 398 insertions(+), 351 deletions(-) delete mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncAuthorizerEvent.cs delete mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncAuthorizerResult.cs rename libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/{AppSyncResolverEventsResult.cs => AppSyncEvent.cs} (67%) rename libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/{AppSyncResolverEvent.cs => AppSyncEventsEvent.cs} (73%) create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsOperation.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResponse.cs delete mode 100644 libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEventsResponse.cs diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncAuthorizerEvent.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncAuthorizerEvent.cs deleted file mode 100644 index 7bc29f7b..00000000 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncAuthorizerEvent.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; - -/// -/// Represents an AWS AppSync authorization event that is sent to a Lambda authorizer -/// for evaluating access permissions to the GraphQL API. -/// -public class AppSyncAuthorizerEvent -{ - /// - /// Gets or sets the authorization token received from the client request. - /// This token is used to make authorization decisions. - /// - public string AuthorizationToken { get; set; } - - /// - /// Gets or sets the headers from the client request. - /// Contains key-value pairs of HTTP header names and their values. - /// - public Dictionary RequestHeaders { get; set; } - - /// - /// Gets or sets the context information about the AppSync request. - /// Contains metadata about the API and the GraphQL operation being executed. - /// - public AppSyncRequestContext RequestContext { get; set; } -} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncAuthorizerResult.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncAuthorizerResult.cs deleted file mode 100644 index 642c64b3..00000000 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncAuthorizerResult.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Text.Json.Serialization; - -namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; - -/// -/// Represents the authorization result returned by a Lambda authorizer to AWS AppSync -/// containing authorization decisions and optional context for the GraphQL API. -/// -public class AppSyncAuthorizerResult -{ - /// - /// Indicates if the request is authorized - /// - [JsonPropertyName("isAuthorized")] - public bool IsAuthorized { get; set; } - - /// - /// Custom context to pass to resolvers, only supports key-value pairs. - /// - [JsonPropertyName("resolverContext")] - public Dictionary ResolverContext { get; set; } - - /// - /// List of fields that are denied access - /// - [JsonPropertyName("deniedFields")] - public IEnumerable DeniedFields { get; set; } - - /// - /// The number of seconds that the response should be cached for - /// - [JsonPropertyName("ttlOverride")] - public int? TtlOverride { get; set; } -} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncCognitoIdentity.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncCognitoIdentity.cs index 51ef1ed6..e59f7949 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncCognitoIdentity.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncCognitoIdentity.cs @@ -8,35 +8,35 @@ public class AppSyncCognitoIdentity /// /// The source IP address of the caller received by AWS AppSync /// - public List SourceIp { get; set; } + public List? SourceIp { get; set; } /// /// The username of the authenticated user /// - public string Username { get; set; } + public string? Username { get; set; } /// /// The UUID of the authenticated user /// - public string Sub { get; set; } + public string? Sub { get; set; } /// /// The claims that the user has /// - public Dictionary Claims { get; set; } + public Dictionary? Claims { get; set; } /// /// The default authorization strategy for this caller (ALLOW or DENY) /// - public string DefaultAuthStrategy { get; set; } + public string? DefaultAuthStrategy { get; set; } /// /// List of OIDC groups /// - public List Groups { get; set; } + public List? Groups { get; set; } /// /// The token issuer /// - public string Issuer { get; set; } + public string? Issuer { get; set; } } \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEventsResult.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEvent.cs similarity index 67% rename from libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEventsResult.cs rename to libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEvent.cs index b4dcd8b6..06e16093 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEventsResult.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEvent.cs @@ -1,6 +1,9 @@ namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; -public class AppSyncResolverEventsResult +/// +/// Represents an event from AWS AppSync. +/// +public class AppSyncEvent { /// /// Payload data when operation succeeds @@ -14,6 +17,7 @@ public class AppSyncResolverEventsResult /// /// Unique identifier for the event + /// This Id is provided by AppSync and needs to be preserved. /// - public string Id { get; set; } = string.Empty; + public required string Id { get; set; } } \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEvent.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsEvent.cs similarity index 73% rename from libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEvent.cs rename to libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsEvent.cs index 2a17dd99..679f3e63 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEvent.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsEvent.cs @@ -3,13 +3,8 @@ namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; /// /// Represents the event payload received from AWS AppSync. /// -public class AppSyncResolverEvent +public class AppSyncEventsEvent { - // /// - // /// Gets or sets the input arguments for the GraphQL operation. - // /// - // public TArguments Arguments { get; set; } - /// /// An object that contains information about the caller. /// Returns null for API_KEY authorization. @@ -35,26 +30,35 @@ public class AppSyncResolverEvent /// /// Gets or sets information about the HTTP request that triggered the event. /// - public RequestContext Request { get; set; } = new(); + public RequestContext? Request { get; set; } /// /// Gets or sets information about the previous state of the data before the operation was executed. /// - public object Prev { get; set; } + public object? Prev { get; set; } /// /// Gets or sets information about the GraphQL operation being executed. /// - public Information Info { get; set; } + public Information? Info { get; set; } /// /// Gets or sets additional information that can be passed between Lambda functions during an AppSync pipeline. /// - public Dictionary Stash { get; set; } + public Dictionary? Stash { get; set; } - public string Error { get; set; } + /// + /// The error message when the operation fails. + /// + public string? Error { get; set; } - public object[] OutErrors { get; set; } + /// + /// The list of error message when the operation fails. + /// + public object[]? OutErrors { get; set; } - public Event[] Events { get; set; } + /// + /// The list of events sent. + /// + public AppSyncEvent[]? Events { 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..758c0c82 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsOperation.cs @@ -0,0 +1,17 @@ +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Represents the operation type for AppSync events. +/// +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/AppSyncEventsResolver.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs index 50c35e40..eba6deea 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs @@ -11,13 +11,13 @@ namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; /// public class AppSyncEventsResolver { - private readonly RouteHandlerRegistry _publishRoutes; - private readonly RouteHandlerRegistry _subscribeRoutes; + private readonly RouteHandlerRegistry _publishRoutes; + private readonly RouteHandlerRegistry _subscribeRoutes; public AppSyncEventsResolver() { - _publishRoutes = new RouteHandlerRegistry(); - _subscribeRoutes = new RouteHandlerRegistry(); + _publishRoutes = new RouteHandlerRegistry(); + _subscribeRoutes = new RouteHandlerRegistry(); } /// @@ -26,7 +26,7 @@ public AppSyncEventsResolver() /// public AppSyncEventsResolver OnPublish(string path, Func, Task> handler) { - _publishRoutes.Register(new RouteHandlerOptions + _publishRoutes.Register(new RouteHandlerOptions { Path = path, Handler = (evt, ctx) => @@ -47,7 +47,7 @@ public AppSyncEventsResolver OnPublish(string path, Func, ILambdaContext, Task> handler) { - _publishRoutes.Register(new RouteHandlerOptions + _publishRoutes.Register(new RouteHandlerOptions { Path = path, Handler = (evt, ctx) => @@ -64,10 +64,10 @@ public AppSyncEventsResolver OnPublish(string path, /// Registers a handler for publish events on a specific channel path. /// Processes all events in a single handler invocation. /// - public AppSyncEventsResolver OnPublish(string path, Func> handler, + public AppSyncEventsResolver OnPublish(string path, Func> handler, bool aggregate = true) { - _publishRoutes.Register(new RouteHandlerOptions + _publishRoutes.Register(new RouteHandlerOptions { Path = path, Handler = (evt, ctx) => handler(evt), @@ -82,9 +82,9 @@ public AppSyncEventsResolver OnPublish(string path, Func public AppSyncEventsResolver OnPublish(string path, - Func> handler, bool aggregate = true) + Func> handler, bool aggregate = true) { - _publishRoutes.Register(new RouteHandlerOptions + _publishRoutes.Register(new RouteHandlerOptions { Path = path, Handler = handler, @@ -96,12 +96,12 @@ public AppSyncEventsResolver OnPublish(string path, /// /// Registers a handler for subscription events on a specific channel path. /// - public AppSyncEventsResolver OnSubscribe(string path, Func> handler) + public AppSyncEventsResolver OnSubscribe(string path, Func> handler) { - _subscribeRoutes.Register(new RouteHandlerOptions + _subscribeRoutes.Register(new RouteHandlerOptions { Path = path, - Handler = async (evt, ctx) => await handler(ExtractSubscriptionInfo(evt)), + Handler = async (evt, ctx) => await handler(evt), Aggregate = true }); return this; @@ -110,18 +110,18 @@ public AppSyncEventsResolver OnSubscribe(string path, Func /// Registers a handler for subscription events on a specific channel path with Lambda context. /// - public AppSyncEventsResolver OnSubscribe(string path, Func> handler) + public AppSyncEventsResolver OnSubscribe(string path, Func> handler) { - _subscribeRoutes.Register(new RouteHandlerOptions + _subscribeRoutes.Register(new RouteHandlerOptions { Path = path, - Handler = async (evt, ctx) => await handler(ExtractSubscriptionInfo(evt), ctx), + Handler = async (evt, ctx) => await handler(evt, ctx), Aggregate = true }); return this; } - public async Task Resolve(AppSyncResolverEvent appsyncEvent, ILambdaContext context) + public async Task Resolve(AppSyncEventsEvent appsyncEvent, ILambdaContext context) { if (IsPublishEvent(appsyncEvent)) { @@ -136,7 +136,7 @@ public async Task Resolve(AppSyncResolverEvent ap throw new InvalidOperationException("Unknown event type"); } - private async Task HandlePublishEvent(AppSyncResolverEvent appsyncEvent, + private async Task HandlePublishEvent(AppSyncEventsEvent appsyncEvent, ILambdaContext context) { var channelPath = appsyncEvent.Info.Channel.Path; @@ -146,16 +146,16 @@ private async Task HandlePublishEvent(AppSyncReso { // Return unchanged events if no handler found var events = appsyncEvent.Events - .Select(e => new AppSyncResolverEventsResult + .Select(e => new AppSyncEvent { Id = e.Id, Payload = e.Payload }) .ToList(); - return new AppSyncResolverEventsResponse { Events = events }; + return new AppSyncEventsResponse { Events = events }; } - var results = new List(); + var results = new List(); if (handlerOptions.Aggregate) { @@ -183,7 +183,7 @@ private async Task HandlePublishEvent(AppSyncReso if (eventDict.TryGetValue("error", out var errorObj) && errorObj != null) { // This is an error result - results.Add(new AppSyncResolverEventsResult + results.Add(new AppSyncEvent { Id = eventId, Error = errorObj.ToString() @@ -195,7 +195,7 @@ private async Task HandlePublishEvent(AppSyncReso var payload = new Dictionary(eventDict); if (payload.ContainsKey("id")) payload.Remove("id"); - results.Add(new AppSyncResolverEventsResult + results.Add(new AppSyncEvent { Id = eventId, Payload = payload @@ -218,7 +218,7 @@ private async Task HandlePublishEvent(AppSyncReso try { // Create a copy of the event with just this single event - var singleEventCopy = new AppSyncResolverEvent + var singleEventCopy = new AppSyncEventsEvent { Info = appsyncEvent.Info, Events = [eventItem] @@ -227,7 +227,7 @@ private async Task HandlePublishEvent(AppSyncReso var handlerResult = await handlerOptions.Handler(singleEventCopy, context); var payload = ConvertToPayload(handlerResult, out var error); - results.Add(new AppSyncResolverEventsResult + results.Add(new AppSyncEvent { Id = eventItem.Id, Payload = payload, @@ -241,43 +241,45 @@ private async Task HandlePublishEvent(AppSyncReso } } - return new AppSyncResolverEventsResponse { Events = results }; + return new AppSyncEventsResponse { Events = results }; } - private async Task HandleSubscribeEvent(AppSyncResolverEvent appsyncEvent, + private async Task HandleSubscribeEvent(AppSyncEventsEvent appsyncEvent, ILambdaContext context) { var channelPath = appsyncEvent.Info.Channel.Path; - var handlerOptions = _subscribeRoutes.ResolveFirst(channelPath); - - if (handlerOptions == null) + + // Check if there's a publish handler for this path + var publishHandler = _publishRoutes.ResolveFirst(channelPath); + if (publishHandler == null) { - return new AppSyncResolverEventsResponse { Authorized = false }; + // No publish handler exists for this path, return null + return null; + } + + var subscribeHandler = _subscribeRoutes.ResolveFirst(channelPath); + if (subscribeHandler == null) + { + // No subscribe handler exists for this path, return null + return null; } try { - var result = await handlerOptions.Handler(appsyncEvent, context); - return new AppSyncResolverEventsResponse { Authorized = (bool)result }; + var result = await subscribeHandler.Handler(appsyncEvent, context); + return new AppSyncEventsResponse { Authorized = result }; + } + catch (UnauthorizedException) + { + throw; } catch (Exception ex) { context.Logger.LogLine($"Error in subscribe handler: {ex.Message}"); - return new AppSyncResolverEventsResponse { Authorized = false }; + return new AppSyncEventsResponse { Error = ex.Message }; } } - private Information ExtractSubscriptionInfo(AppSyncResolverEvent appsyncEvent) - { - return new Information - { - Channel = new Channel - { - Path = appsyncEvent.Info.Channel.Path - } - }; - } - private Dictionary ConvertToPayload(object result, out string error) { error = null; @@ -298,22 +300,29 @@ private Dictionary ConvertToPayload(object result, out string er return new Dictionary { ["data"] = result }; } - private AppSyncResolverEventsResult FormatErrorResponse(Exception ex, string id) + private AppSyncEvent FormatErrorResponse(Exception ex, string id) { - return new AppSyncResolverEventsResult + return new AppSyncEvent { Id = id, // This will be the original event ID or null Error = $"{ex.GetType().Name} - {ex.Message}" }; } - private bool IsPublishEvent(AppSyncResolverEvent appsyncEvent) + private bool IsPublishEvent(AppSyncEventsEvent appsyncEvent) { - return appsyncEvent.Info.Operation == AppsyncEventsOperation.Publish; + return appsyncEvent.Info.Operation == AppSyncEventsOperation.Publish; } - private bool IsSubscribeEvent(AppSyncResolverEvent appsyncEvent) + private bool IsSubscribeEvent(AppSyncEventsEvent appsyncEvent) + { + return appsyncEvent.Info.Operation == AppSyncEventsOperation.Subscribe; + } +} + +public class UnauthorizedException : Exception +{ + public UnauthorizedException(string message) : base(message) { - return appsyncEvent.Info.Operation == AppsyncEventsOperation.Subscribe; } } \ 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..9c5e832a --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResponse.cs @@ -0,0 +1,22 @@ +namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; + +/// +/// Represents the response for AppSync events. +/// +public class AppSyncEventsResponse +{ + /// + /// Collection of event results + /// + public List? Events { get; set; } + + /// + /// Used for OnSubscribe to determine if the subscription should be authorized + /// + public bool? Authorized { get; set; } + + /// + /// When operation fails, this will contain the error message + /// + 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 index 540dfce5..f2cf0a17 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncIamIdentity.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncIamIdentity.cs @@ -8,40 +8,40 @@ public class AppSyncIamIdentity /// /// The source IP address of the caller received by AWS AppSync /// - public List SourceIp { get; set; } + public List? SourceIp { get; set; } /// /// The username of the authenticated user (IAM user principal) /// - public string Username { get; set; } + public string? Username { get; set; } /// /// The AWS account ID of the caller /// - public string AccountId { get; set; } + public string? AccountId { get; set; } /// /// The Amazon Cognito identity pool ID associated with the caller /// - public string CognitoIdentityPoolId { get; set; } + public string? CognitoIdentityPoolId { get; set; } /// /// The Amazon Cognito identity ID of the caller /// - public string CognitoIdentityId { get; set; } + public string? CognitoIdentityId { get; set; } /// /// The ARN of the IAM user /// - public string UserArn { get; set; } + public string? UserArn { get; set; } /// /// Either authenticated or unauthenticated based on the identity type /// - public string CognitoIdentityAuthType { get; set; } + 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; } + 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 index 46388670..996aa9a0 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncLambdaIdentity.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncLambdaIdentity.cs @@ -9,5 +9,5 @@ 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; } + public Dictionary? ResolverContext { 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 index 890aa370..3fe5681d 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncRequestContext.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncRequestContext.cs @@ -9,32 +9,32 @@ public class AppSyncRequestContext /// /// Gets or sets the unique identifier of the AppSync API. /// - public string ApiId { get; set; } + public string? ApiId { get; set; } /// /// Gets or sets the AWS account ID where the AppSync API is deployed. /// - public string AccountId { get; set; } + public string? AccountId { get; set; } /// /// Gets or sets the unique identifier for this specific request. /// - public string RequestId { get; set; } + public string? RequestId { get; set; } /// /// Gets or sets the GraphQL query string containing the operation to be executed. /// - public string QueryString { get; set; } + 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; } + 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; } + public Dictionary? Variables { get; set; } } \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEventsResponse.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEventsResponse.cs deleted file mode 100644 index 6c3c747b..00000000 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncResolverEventsResponse.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; - -public class AppSyncResolverEventsResponse -{ - /// - /// Collection of event results - /// - public List Events { get; set; } = new(); - - public bool Authorized { 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 index d9655f06..2591f5cf 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Channel.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Channel.cs @@ -8,10 +8,10 @@ public class Channel /// /// Provides direct access to the 'Path' attribute within the 'Channel' object. /// - public string Path { get; set; } + public required string Path { get; set; } /// /// Provides direct access to the 'Segments' attribute within the 'Channel' object. /// - public string[] Segments { get; set; } + public required 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 index ffe99b15..643d3f7f 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/ChannelNamespace.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/ChannelNamespace.cs @@ -5,5 +5,8 @@ namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; /// public class ChannelNamespace { - public string Name { get; set; } + /// + /// Name of the channel namespace + /// + 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 index 317261d9..aff7005b 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Information.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Information.cs @@ -1,61 +1,22 @@ namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; /// -/// Represents information about the GraphQL operation being executed. +/// Represents information about the AppSync event. /// public class Information { /// - /// Gets or sets the name of the GraphQL field being executed. + /// The channel being used for the operation /// - public string FieldName { get; set; } - - /// - /// Gets or sets a list of fields being selected in the GraphQL operation. - /// - public List SelectionSetList { get; set; } - - /// - /// Gets or sets the GraphQL selection set for the operation. - /// - public string SelectionSetGraphQL { get; set; } - - /// - /// Gets or sets the variables passed to the GraphQL operation. - /// - public Dictionary Variables { get; set; } - + public required Channel Channel { get; set; } + /// - /// Gets or sets the parent type name for the GraphQL operation. + /// The namespace of the channel /// - public string ParentTypeName { get; set; } - - public Channel Channel { get; set; } - public ChannelNamespace ChannelNamespace { get; set; } + public required ChannelNamespace ChannelNamespace { get; set; } /// /// The operation being performed (e.g., Publish, Subscribe) /// - public AppsyncEventsOperation Operation { get; set; } -} - -public enum AppsyncEventsOperation -{ - /// - /// Represents a subscription operation. - /// - Subscribe, - - /// - /// Represents a publish operation. - /// - Publish -} - -public class Event -{ - public Dictionary Payload { get; set; } - - public string Id { get; set; } -} - + public required AppSyncEventsOperation Operation { 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 index 465214da..9f1972d1 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs @@ -8,7 +8,7 @@ namespace AWS.Lambda.Powertools.EventHandler.Internal; /// /// Simple LRU cache implementation for caching route resolutions /// -internal class LRUCache +internal class LRUCache where TKey : notnull { private readonly int _capacity; private readonly Dictionary> _cache; @@ -33,7 +33,7 @@ public LRUCache(int capacity) _lruList = new LinkedList(); } - public bool TryGet(TKey key, out TValue value) + public bool TryGet(TKey key, out TValue? value) { if (_cache.TryGetValue(key, out var node)) { @@ -60,7 +60,7 @@ public void Set(TKey key, TValue value) // Remove least recently used item var lastNode = _lruList.Last; _lruList.RemoveLast(); - _cache.Remove(lastNode.Value.Key); + if (lastNode != null) _cache.Remove(lastNode.Value.Key); } var newNode = new LinkedListNode(new CacheItem(key, value)); diff --git a/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs index 8477f20d..74c5af2a 100644 --- a/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs @@ -8,11 +8,11 @@ namespace AWS.Lambda.Powertools.EventHandler.Tests; public class AppSyncEventsTests { - private readonly AppSyncResolverEvent? _appSyncEvent; + private readonly AppSyncEventsEvent? _appSyncEvent; public AppSyncEventsTests() { - _appSyncEvent = JsonSerializer.Deserialize( + _appSyncEvent = JsonSerializer.Deserialize( File.ReadAllText("appSyncEventsEvent.json"), new JsonSerializerOptions { @@ -131,13 +131,20 @@ public async Task Should_Authorize_Subscription() var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); + app.OnPublish("/default/channel", async (payload) => payload); + app.OnSubscribe("/default/*", async (info) => true); - var subscribeEvent = new AppSyncResolverEvent + var subscribeEvent = new AppSyncEventsEvent { Info = new Information { - Channel = new Channel { Path = "/default/channel" }, - Operation = AppsyncEventsOperation.Subscribe + Channel = new Channel + { + Path = "/default/channel", + Segments = ["default", "channel"] + }, + Operation = AppSyncEventsOperation.Subscribe, + ChannelNamespace = new ChannelNamespace{ Name = "default" } } }; // Act @@ -153,14 +160,17 @@ public async Task Should_Deny_Subscription() // Arrange var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - + + app.OnPublish("/default/channel", async (payload) => payload); + app.OnSubscribe("/default/*", async (info) => false); - var subscribeEvent = new AppSyncResolverEvent + var subscribeEvent = new AppSyncEventsEvent { Info = new Information { - Channel = new Channel { Path = "/default/channel" }, - Operation = AppsyncEventsOperation.Subscribe + Channel = new Channel { Path = "/default/channel", Segments = ["default", "channel"]}, + Operation = AppSyncEventsOperation.Subscribe, + ChannelNamespace = new ChannelNamespace{ Name = "default" } } }; // Act @@ -176,15 +186,18 @@ public async Task Should_Deny_Subscription_On_Exception() // Arrange var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - + + app.OnPublish("/default/channel", async (payload) => payload); + app.OnSubscribe("/default/*", async (info) => { throw new Exception("Authorization error"); }); - var subscribeEvent = new AppSyncResolverEvent + var subscribeEvent = new AppSyncEventsEvent { Info = new Information { - Channel = new Channel { Path = "/default/channel" }, - Operation = AppsyncEventsOperation.Subscribe + Channel = new Channel { Path = "/default/channel", Segments = ["default", "channel"] }, + Operation = AppSyncEventsOperation.Subscribe, + ChannelNamespace = new ChannelNamespace{ Name = "default" } } }; @@ -192,7 +205,7 @@ public async Task Should_Deny_Subscription_On_Exception() var result = await app.Resolve(subscribeEvent, lambdaContext); // Assert - Assert.False(result.Authorized); + Assert.Equal("Authorization error", result.Error); } [Fact] @@ -249,12 +262,13 @@ public async Task Should_Throw_For_Unknown_EventType() var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - var unknownEvent = new AppSyncResolverEvent + var unknownEvent = new AppSyncEventsEvent { Info = new Information { - Channel = new Channel { Path = "/default/channel" }, - Operation = (AppsyncEventsOperation)999 // Unknown operation + Channel = new Channel { Path = "/default/channel", Segments = ["default", "channel"] }, + Operation = (AppSyncEventsOperation)999, // Unknown operation + ChannelNamespace = new ChannelNamespace{ Name = "default" } } }; @@ -441,152 +455,236 @@ public async Task Should_Verify_Ids_Are_Preserved_In_Error_Case() 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.OnPublish("/default/channel", async (payload) => - { - firstHandlerCalls++; - return new Dictionary { ["handler"] = "first" }; - }); - - app.OnPublish("/default/*", async (payload) => - { - secondHandlerCalls++; - return new Dictionary { ["handler"] = "second" }; - }); - - // Act - var result = await app.Resolve(_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(); + public async Task Should_Match_Most_Specific_Handler_Only() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + int firstHandlerCalls = 0; + int secondHandlerCalls = 0; - // Create an event with multiple keys in the payload - var multiKeyEvent = new AppSyncResolverEvent + app.OnPublish("/default/channel", async (payload) => + { + firstHandlerCalls++; + return new Dictionary { ["handler"] = "first" }; + }); + + app.OnPublish("/default/*", async (payload) => + { + secondHandlerCalls++; + return new Dictionary { ["handler"] = "second" }; + }); + + // Act + var result = await app.Resolve(_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() { - Info = new Information + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + // Create an event with multiple keys in the payload + var multiKeyEvent = new AppSyncEventsEvent { - Channel = new Channel { Path = "/default/channel" }, - Operation = AppsyncEventsOperation.Publish - }, - Events = - [ - new Event + Info = new Information { - Id = "1", - Payload = new Dictionary + Channel = new Channel { Path = "/default/channel", Segments = ["default", "channel"] }, + Operation = AppSyncEventsOperation.Publish, + ChannelNamespace = new ChannelNamespace{ Name = "default" } + }, + Events = + [ + new AppSyncEvent { - ["event_1"] = "data_1", - ["event_1a"] = "data_1a" + Id = "1", + Payload = new Dictionary + { + ["event_1"] = "data_1", + ["event_1a"] = "data_1a" + } } - } - ] - }; + ] + }; + + app.OnPublish("/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.Resolve(multiKeyEvent, lambdaContext); - app.OnPublish("/default/channel", async (payload) => + // 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() { - // 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"] - }; - }); + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + // Register handlers with different specificity + app.OnPublish("/*", async (payload) => + new Dictionary { ["handler"] = "least-specific" }); - // Act - var result = await app.Resolve(multiKeyEvent, lambdaContext); + app.OnPublish("/default/*", async (payload) => + new Dictionary { ["handler"] = "more-specific" }); - // 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"]); -} + app.OnPublish("/default/channel", async (payload) => + new Dictionary { ["handler"] = "most-specific" }); -[Fact] -public async Task Should_Only_Use_First_Matching_Handler_By_Specificity() -{ - // Arrange - var lambdaContext = new TestLambdaContext(); - var app = new AppSyncEventsResolver(); + // Act + var result = await app.Resolve(_appSyncEvent, lambdaContext); - // Register handlers with different specificity - app.OnPublish("/*", async (payload) => - new Dictionary { ["handler"] = "least-specific" }); + // 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()); + } - app.OnPublish("/default/*", async (payload) => - new Dictionary { ["handler"] = "more-specific" }); + [Fact] + public async Task Should_Fallback_To_Less_Specific_Handler_If_No_Exact_Match() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); - app.OnPublish("/default/channel", async (payload) => - new Dictionary { ["handler"] = "most-specific" }); + // Create an event with a path that has no exact match + var fallbackEvent = new AppSyncEventsEvent + { + 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" } + } + ] + }; - // Act - var result = await app.Resolve(_appSyncEvent, lambdaContext); + app.OnPublish("/default/*", async (payload) => + new Dictionary { ["handler"] = "wildcard-handler" }); - // 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()); -} + // Act + var result = await app.Resolve(fallbackEvent, lambdaContext); -[Fact] -public async Task Should_Fallback_To_Less_Specific_Handler_If_No_Exact_Match() -{ - // Arrange - var lambdaContext = new TestLambdaContext(); - var app = new AppSyncEventsResolver(); + // Assert + Assert.Single(result.Events); + Assert.Equal("wildcard-handler", result.Events[0].Payload["handler"].ToString()); + } - // Create an event with a path that has no exact match - var fallbackEvent = new AppSyncResolverEvent + [Fact] + public async Task Should_Return_Null_When_Subscribing_To_Path_Without_Publish_Handler() { - Info = new Information + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + // Only set up a subscribe handler without corresponding publish handler + app.OnSubscribe("/subscribe-only", async (info) => true); + + var subscribeEvent = new AppSyncEventsEvent { - Channel = new Channel { Path = "/default/specific/path" }, - Operation = AppsyncEventsOperation.Publish - }, - Events = - [ - new Event + Info = new Information { - Id = "1", - Payload = new Dictionary { ["key"] = "value" } + Channel = new Channel { Path = "/subscribe-only", Segments = ["subscribe-only"] }, + Operation = AppSyncEventsOperation.Subscribe, + ChannelNamespace = new ChannelNamespace{ Name = "default" } } - ] - }; + }; - app.OnPublish("/default/*", async (payload) => - new Dictionary { ["handler"] = "wildcard-handler" }); + // Act + var result = await app.Resolve(subscribeEvent, lambdaContext); - // Act - var result = await app.Resolve(fallbackEvent, lambdaContext); + // Assert + Assert.Null(result); + } + + [Fact] + public async Task Should_Return_Null_When_Subscribing_To_Path_With_No_Match_Publish_Handler() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/default/channel", async (payload) => payload); + app.OnSubscribe("/default/channel1", async (info) => true); - // Assert - Assert.Single(result.Events); - Assert.Equal("wildcard-handler", result.Events[0].Payload["handler"].ToString()); -} + var subscribeEvent = new AppSyncEventsEvent + { + 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.Resolve(subscribeEvent, lambdaContext); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task Should_Return_UnauthorizedException_When_Throwing_UnauthorizedException() + { + // Arrange + var lambdaContext = new TestLambdaContext(); + var app = new AppSyncEventsResolver(); + + app.OnPublish("/default/channel", async (payload) => payload); + app.OnSubscribe("/default/channel", async (info, lambdaContext) => + { + throw new UnauthorizedException("OOPS"); + }); + + var subscribeEvent = new AppSyncEventsEvent + { + Info = new Information + { + Channel = new Channel { Path = "/default/channel", Segments = ["default", "channel"] }, + Operation = AppSyncEventsOperation.Subscribe, + ChannelNamespace = new ChannelNamespace{ Name = "default" } + } + }; + + // Act && Assert + await Assert.ThrowsAsync(() => + app.Resolve(subscribeEvent, lambdaContext)); + } } \ No newline at end of file From bbb8bfbb7c0bcff7e20c3241aff73c617d1f5040 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Thu, 17 Apr 2025 17:24:08 +0100 Subject: [PATCH 04/16] feat: refactor AppSync event handling to use nullable properties and rename event classes --- .../AppSyncEvents/AppSyncEvent.cs | 8 +- .../AppSyncEvents/AppSyncEventsOperation.cs | 3 + ...EventsEvent.cs => AppSyncEventsRequest.cs} | 6 +- .../AppSyncEvents/AppSyncEventsResolver.cs | 82 ++++++++++------ .../AppSyncEvents/AppSyncEventsResponse.cs | 10 +- .../AppSyncEvents/Channel.cs | 4 +- .../AppSyncEvents/Information.cs | 8 +- .../Internal/RouteHandlerRegistry.cs | 8 ++ .../AppSyncEventsTests.cs | 94 ++++++++++--------- 9 files changed, 136 insertions(+), 87 deletions(-) rename libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/{AppSyncEventsEvent.cs => AppSyncEventsRequest.cs} (92%) diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEvent.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEvent.cs index 06e16093..9ed03423 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEvent.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEvent.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; /// @@ -8,16 +10,20 @@ 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. /// - public required string Id { get; set; } + [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 index 758c0c82..ffb970dd 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsOperation.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsOperation.cs @@ -1,8 +1,11 @@ +using System.Text.Json.Serialization; + namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; /// /// Represents the operation type for AppSync events. /// +[JsonConverter(typeof(JsonStringEnumConverter))] public enum AppSyncEventsOperation { /// diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsEvent.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsRequest.cs similarity index 92% rename from libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsEvent.cs rename to libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsRequest.cs index 679f3e63..289ec56e 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsEvent.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsRequest.cs @@ -1,9 +1,11 @@ +using System.Text.Json.Serialization; + namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; /// /// Represents the event payload received from AWS AppSync. /// -public class AppSyncEventsEvent +public class AppSyncEventsRequest { /// /// An object that contains information about the caller. @@ -50,6 +52,7 @@ public class AppSyncEventsEvent /// /// The error message when the operation fails. /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public string? Error { get; set; } /// @@ -60,5 +63,6 @@ public class AppSyncEventsEvent /// /// 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 index eba6deea..ab1af512 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs @@ -11,13 +11,13 @@ namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; /// public class AppSyncEventsResolver { - private readonly RouteHandlerRegistry _publishRoutes; - private readonly RouteHandlerRegistry _subscribeRoutes; + private readonly RouteHandlerRegistry _publishRoutes; + private readonly RouteHandlerRegistry _subscribeRoutes; public AppSyncEventsResolver() { - _publishRoutes = new RouteHandlerRegistry(); - _subscribeRoutes = new RouteHandlerRegistry(); + _publishRoutes = new RouteHandlerRegistry(); + _subscribeRoutes = new RouteHandlerRegistry(); } /// @@ -26,7 +26,7 @@ public AppSyncEventsResolver() /// public AppSyncEventsResolver OnPublish(string path, Func, Task> handler) { - _publishRoutes.Register(new RouteHandlerOptions + _publishRoutes.Register(new RouteHandlerOptions { Path = path, Handler = (evt, ctx) => @@ -47,7 +47,7 @@ public AppSyncEventsResolver OnPublish(string path, Func, ILambdaContext, Task> handler) { - _publishRoutes.Register(new RouteHandlerOptions + _publishRoutes.Register(new RouteHandlerOptions { Path = path, Handler = (evt, ctx) => @@ -64,10 +64,10 @@ public AppSyncEventsResolver OnPublish(string path, /// Registers a handler for publish events on a specific channel path. /// Processes all events in a single handler invocation. /// - public AppSyncEventsResolver OnPublish(string path, Func> handler, + public AppSyncEventsResolver OnPublish(string path, Func> handler, bool aggregate = true) { - _publishRoutes.Register(new RouteHandlerOptions + _publishRoutes.Register(new RouteHandlerOptions { Path = path, Handler = (evt, ctx) => handler(evt), @@ -82,9 +82,9 @@ public AppSyncEventsResolver OnPublish(string path, Func public AppSyncEventsResolver OnPublish(string path, - Func> handler, bool aggregate = true) + Func> handler, bool aggregate = true) { - _publishRoutes.Register(new RouteHandlerOptions + _publishRoutes.Register(new RouteHandlerOptions { Path = path, Handler = handler, @@ -96,9 +96,9 @@ public AppSyncEventsResolver OnPublish(string path, /// /// Registers a handler for subscription events on a specific channel path. /// - public AppSyncEventsResolver OnSubscribe(string path, Func> handler) + public AppSyncEventsResolver OnSubscribe(string path, Func> handler) { - _subscribeRoutes.Register(new RouteHandlerOptions + _subscribeRoutes.Register(new RouteHandlerOptions { Path = path, Handler = async (evt, ctx) => await handler(evt), @@ -110,9 +110,9 @@ public AppSyncEventsResolver OnSubscribe(string path, Func /// Registers a handler for subscription events on a specific channel path with Lambda context. /// - public AppSyncEventsResolver OnSubscribe(string path, Func> handler) + public AppSyncEventsResolver OnSubscribe(string path, Func> handler) { - _subscribeRoutes.Register(new RouteHandlerOptions + _subscribeRoutes.Register(new RouteHandlerOptions { Path = path, Handler = async (evt, ctx) => await handler(evt, ctx), @@ -121,7 +121,7 @@ public AppSyncEventsResolver OnSubscribe(string path, Func Resolve(AppSyncEventsEvent appsyncEvent, ILambdaContext context) + public async Task Resolve(AppSyncEventsRequest appsyncEvent, ILambdaContext context) { if (IsPublishEvent(appsyncEvent)) { @@ -136,11 +136,14 @@ public async Task Resolve(AppSyncEventsEvent appsyncEvent throw new InvalidOperationException("Unknown event type"); } - private async Task HandlePublishEvent(AppSyncEventsEvent appsyncEvent, + 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) { @@ -207,7 +210,12 @@ private async Task HandlePublishEvent(AppSyncEventsEvent } catch (Exception ex) { - results.Add(FormatErrorResponse(ex, null)); + // results.Add(FormatErrorResponse(ex, null)); + return new AppSyncEventsResponse + { + Error = ex.Message, + + }; } } else @@ -217,8 +225,9 @@ private async Task HandlePublishEvent(AppSyncEventsEvent { try { + context.Logger.LogInformation($"Handling event item: {eventItem.Id}"); // Create a copy of the event with just this single event - var singleEventCopy = new AppSyncEventsEvent + var singleEventCopy = new AppSyncEventsRequest { Info = appsyncEvent.Info, Events = [eventItem] @@ -244,30 +253,41 @@ private async Task HandlePublishEvent(AppSyncEventsEvent return new AppSyncEventsResponse { Events = results }; } - private async Task HandleSubscribeEvent(AppSyncEventsEvent appsyncEvent, + /// + /// Handles subscription events. + /// Null is successful, otherwise returns an error message. + /// + /// + /// + /// + private async Task HandleSubscribeEvent(AppSyncEventsRequest appsyncEvent, ILambdaContext context) { var channelPath = appsyncEvent.Info.Channel.Path; - - // Check if there's a publish handler for this path - var publishHandler = _publishRoutes.ResolveFirst(channelPath); - if (publishHandler == null) + var channelBase = $"/{appsyncEvent.Info.Channel.Segments[0]}"; + + // Find matching subscribe handler + var subscribeHandler = _subscribeRoutes.ResolveFirst(channelPath); + if (subscribeHandler == null) { - // No publish handler exists for this path, return null return null; } - - var subscribeHandler = _subscribeRoutes.ResolveFirst(channelPath); - if (subscribeHandler == null) + + // Check if there's ANY publish handler for the base channel namespace + // This ensures we don't require exact path matches between publish and subscribe + bool hasAnyPublishHandler = _publishRoutes.GetAllHandlers() + .Any(h => h.Path.StartsWith(channelBase)); + + if (!hasAnyPublishHandler) { - // No subscribe handler exists for this path, return null return null; } try { var result = await subscribeHandler.Handler(appsyncEvent, context); - return new AppSyncEventsResponse { Authorized = result }; + return !result ? + new AppSyncEventsResponse { Error = "Subscription failed" } : null; } catch (UnauthorizedException) { @@ -309,12 +329,12 @@ private AppSyncEvent FormatErrorResponse(Exception ex, string id) }; } - private bool IsPublishEvent(AppSyncEventsEvent appsyncEvent) + private bool IsPublishEvent(AppSyncEventsRequest appsyncEvent) { return appsyncEvent.Info.Operation == AppSyncEventsOperation.Publish; } - private bool IsSubscribeEvent(AppSyncEventsEvent appsyncEvent) + private bool IsSubscribeEvent(AppSyncEventsRequest appsyncEvent) { return appsyncEvent.Info.Operation == AppSyncEventsOperation.Subscribe; } diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResponse.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResponse.cs index 9c5e832a..4619ee71 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResponse.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResponse.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; /// @@ -8,15 +10,13 @@ public class AppSyncEventsResponse /// /// Collection of event results /// + [JsonPropertyName("events")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public List? Events { get; set; } - /// - /// Used for OnSubscribe to determine if the subscription should be authorized - /// - public bool? Authorized { get; set; } - /// /// When operation fails, this will contain the error message /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public string? Error { 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 index 2591f5cf..817faf30 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Channel.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Channel.cs @@ -8,10 +8,10 @@ public class Channel /// /// Provides direct access to the 'Path' attribute within the 'Channel' object. /// - public required string Path { get; set; } + public string? Path { get; set; } /// /// Provides direct access to the 'Segments' attribute within the 'Channel' object. /// - public required string[] Segments { get; set; } + public string[]? Segments { 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 index aff7005b..a65af8d5 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Information.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Information.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; /// @@ -8,15 +10,15 @@ public class Information /// /// The channel being used for the operation /// - public required Channel Channel { get; set; } + public Channel Channel { get; set; } /// /// The namespace of the channel /// - public required ChannelNamespace ChannelNamespace { get; set; } + public ChannelNamespace ChannelNamespace { get; set; } /// /// The operation being performed (e.g., Publish, Subscribe) /// - public required AppSyncEventsOperation Operation { get; set; } + public AppSyncEventsOperation Operation { get; set; } } \ 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 index 99438b57..24ad8666 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerRegistry.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerRegistry.cs @@ -86,6 +86,14 @@ public void Register(RouteHandlerOptions options) return null; } + + /// + /// Get all registered handlers + /// + public IEnumerable> GetAllHandlers() + { + return _resolvers.Values; + } /// /// Check if a path pattern is valid according to routing rules. diff --git a/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs index 74c5af2a..faa2dc36 100644 --- a/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs @@ -8,11 +8,11 @@ namespace AWS.Lambda.Powertools.EventHandler.Tests; public class AppSyncEventsTests { - private readonly AppSyncEventsEvent? _appSyncEvent; + private readonly AppSyncEventsRequest? _appSyncEvent; public AppSyncEventsTests() { - _appSyncEvent = JsonSerializer.Deserialize( + _appSyncEvent = JsonSerializer.Deserialize( File.ReadAllText("appSyncEventsEvent.json"), new JsonSerializerOptions { @@ -132,9 +132,9 @@ public async Task Should_Authorize_Subscription() var app = new AppSyncEventsResolver(); app.OnPublish("/default/channel", async (payload) => payload); - + app.OnSubscribe("/default/*", async (info) => true); - var subscribeEvent = new AppSyncEventsEvent + var subscribeEvent = new AppSyncEventsRequest { Info = new Information { @@ -144,14 +144,14 @@ public async Task Should_Authorize_Subscription() Segments = ["default", "channel"] }, Operation = AppSyncEventsOperation.Subscribe, - ChannelNamespace = new ChannelNamespace{ Name = "default" } + ChannelNamespace = new ChannelNamespace { Name = "default" } } }; // Act var result = await app.Resolve(subscribeEvent, lambdaContext); // Assert - Assert.True(result.Authorized); + Assert.Null(result); } [Fact] @@ -160,24 +160,24 @@ public async Task Should_Deny_Subscription() // Arrange var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - + app.OnPublish("/default/channel", async (payload) => payload); - + app.OnSubscribe("/default/*", async (info) => false); - var subscribeEvent = new AppSyncEventsEvent + var subscribeEvent = new AppSyncEventsRequest { Info = new Information { - Channel = new Channel { Path = "/default/channel", Segments = ["default", "channel"]}, + Channel = new Channel { Path = "/default/channel", Segments = ["default", "channel"] }, Operation = AppSyncEventsOperation.Subscribe, - ChannelNamespace = new ChannelNamespace{ Name = "default" } + ChannelNamespace = new ChannelNamespace { Name = "default" } } }; // Act var result = await app.Resolve(subscribeEvent, lambdaContext); // Assert - Assert.False(result.Authorized); + Assert.NotNull(result.Error); } [Fact] @@ -186,18 +186,18 @@ public async Task Should_Deny_Subscription_On_Exception() // Arrange var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - + app.OnPublish("/default/channel", async (payload) => payload); - + app.OnSubscribe("/default/*", async (info) => { throw new Exception("Authorization error"); }); - var subscribeEvent = new AppSyncEventsEvent + var subscribeEvent = new AppSyncEventsRequest { Info = new Information { Channel = new Channel { Path = "/default/channel", Segments = ["default", "channel"] }, Operation = AppSyncEventsOperation.Subscribe, - ChannelNamespace = new ChannelNamespace{ Name = "default" } + ChannelNamespace = new ChannelNamespace { Name = "default" } } }; @@ -262,13 +262,13 @@ public async Task Should_Throw_For_Unknown_EventType() var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - var unknownEvent = new AppSyncEventsEvent + 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" } + ChannelNamespace = new ChannelNamespace { Name = "default" } } }; @@ -496,13 +496,13 @@ public async Task Should_Handle_Multiple_Keys_In_Payload() var app = new AppSyncEventsResolver(); // Create an event with multiple keys in the payload - var multiKeyEvent = new AppSyncEventsEvent + var multiKeyEvent = new AppSyncEventsRequest { Info = new Information { Channel = new Channel { Path = "/default/channel", Segments = ["default", "channel"] }, Operation = AppSyncEventsOperation.Publish, - ChannelNamespace = new ChannelNamespace{ Name = "default" } + ChannelNamespace = new ChannelNamespace { Name = "default" } }, Events = [ @@ -577,13 +577,13 @@ public async Task Should_Fallback_To_Less_Specific_Handler_If_No_Exact_Match() var app = new AppSyncEventsResolver(); // Create an event with a path that has no exact match - var fallbackEvent = new AppSyncEventsEvent + 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" } + ChannelNamespace = new ChannelNamespace { Name = "default" } }, Events = [ @@ -605,7 +605,7 @@ public async Task Should_Fallback_To_Less_Specific_Handler_If_No_Exact_Match() 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() { @@ -616,13 +616,13 @@ public async Task Should_Return_Null_When_Subscribing_To_Path_Without_Publish_Ha // Only set up a subscribe handler without corresponding publish handler app.OnSubscribe("/subscribe-only", async (info) => true); - var subscribeEvent = new AppSyncEventsEvent + var subscribeEvent = new AppSyncEventsRequest { Info = new Information { Channel = new Channel { Path = "/subscribe-only", Segments = ["subscribe-only"] }, Operation = AppSyncEventsOperation.Subscribe, - ChannelNamespace = new ChannelNamespace{ Name = "default" } + ChannelNamespace = new ChannelNamespace { Name = "default" } } }; @@ -632,24 +632,27 @@ public async Task Should_Return_Null_When_Subscribing_To_Path_Without_Publish_Ha // Assert Assert.Null(result); } - - [Fact] - public async Task Should_Return_Null_When_Subscribing_To_Path_With_No_Match_Publish_Handler() + + [Theory] + [InlineData("/default/channel", "/default/channel1")] + [InlineData("/default/channel3", "/default/channel")] + public async Task 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("/default/channel", async (payload) => payload); - app.OnSubscribe("/default/channel1", async (info) => true); + app.OnPublish(publishPath, async (payload) => payload); + app.OnSubscribe(subscribePath, async (info) => true); - var subscribeEvent = new AppSyncEventsEvent + var subscribeEvent = new AppSyncEventsRequest { Info = new Information { - Channel = new Channel { Path = "/default/channel", Segments = ["default", "channel"] }, + Channel = new Channel { Path = subscribePath, Segments = ["default", "channel"] }, Operation = AppSyncEventsOperation.Subscribe, - ChannelNamespace = new ChannelNamespace{ Name = "default" } + ChannelNamespace = new ChannelNamespace { Name = "default" } } }; @@ -659,27 +662,30 @@ public async Task Should_Return_Null_When_Subscribing_To_Path_With_No_Match_Publ // Assert Assert.Null(result); } - - [Fact] - public async Task Should_Return_UnauthorizedException_When_Throwing_UnauthorizedException() + + [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.OnPublish("/default/channel", async (payload) => payload); - app.OnSubscribe("/default/channel", async (info, lambdaContext) => - { - throw new UnauthorizedException("OOPS"); - }); + app.OnPublish(publishPath, async (payload) => payload); + app.OnSubscribe(subscribePath, + async (info, lambdaContext) => { throw new UnauthorizedException("OOPS"); }); - var subscribeEvent = new AppSyncEventsEvent + var subscribeEvent = new AppSyncEventsRequest { Info = new Information { - Channel = new Channel { Path = "/default/channel", Segments = ["default", "channel"] }, + Channel = new Channel { Path = subscribePath, Segments = ["default", "channel"] }, Operation = AppSyncEventsOperation.Subscribe, - ChannelNamespace = new ChannelNamespace{ Name = "default" } + ChannelNamespace = new ChannelNamespace { Name = "default" } } }; From d214d1b758e005f38a014991719028f99264c1a6 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Fri, 18 Apr 2025 16:56:57 +0100 Subject: [PATCH 05/16] feat: update AppSyncEventsResolver to use async handlers and improve error handling --- .../AppSyncEvents/AppSyncEventsResolver.cs | 94 ++++++------------- .../AppSyncEventsTests.cs | 84 ++++++++++++----- 2 files changed, 92 insertions(+), 86 deletions(-) diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs index ab1af512..2479f0d9 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs @@ -29,10 +29,10 @@ public AppSyncEventsResolver OnPublish(string path, Func { Path = path, - Handler = (evt, ctx) => + Handler = async (evt, ctx) => { - var payload = evt.Events.FirstOrDefault()?.Payload; - return handler(payload ?? new Dictionary()); + var payload = evt.Events?.FirstOrDefault()?.Payload; + return await handler(payload ?? new Dictionary()); }, Aggregate = false }); @@ -50,10 +50,10 @@ public AppSyncEventsResolver OnPublish(string path, _publishRoutes.Register(new RouteHandlerOptions { Path = path, - Handler = (evt, ctx) => + Handler = async (evt, ctx) => { - var payload = evt.Events.FirstOrDefault()?.Payload; - return handler(payload ?? new Dictionary(), ctx); + var payload = evt.Events?.FirstOrDefault()?.Payload; + return await handler(payload ?? new Dictionary(), ctx); }, Aggregate = false }); @@ -64,14 +64,13 @@ public AppSyncEventsResolver OnPublish(string path, /// Registers a handler for publish events on a specific channel path. /// Processes all events in a single handler invocation. /// - public AppSyncEventsResolver OnPublish(string path, Func> handler, - bool aggregate = true) + public AppSyncEventsResolver OnPublishAggregate(string path, Func> handler) { _publishRoutes.Register(new RouteHandlerOptions { Path = path, - Handler = (evt, ctx) => handler(evt), - Aggregate = aggregate + Handler = async (evt, ctx) => await handler(evt), + Aggregate = true }); return this; } @@ -81,14 +80,14 @@ public AppSyncEventsResolver OnPublish(string path, Func - public AppSyncEventsResolver OnPublish(string path, - Func> handler, bool aggregate = true) + public AppSyncEventsResolver OnPublishAggregate(string path, + Func> handler) { _publishRoutes.Register(new RouteHandlerOptions { Path = path, - Handler = handler, - Aggregate = aggregate + Handler = async (evt, ctx) => (object)await handler(evt, ctx), + Aggregate = true }); return this; } @@ -101,8 +100,7 @@ public AppSyncEventsResolver OnSubscribe(string path, Func { Path = path, - Handler = async (evt, ctx) => await handler(evt), - Aggregate = true + Handler = async (evt, ctx) => await handler(evt) }); return this; } @@ -115,13 +113,12 @@ public AppSyncEventsResolver OnSubscribe(string path, Func { Path = path, - Handler = async (evt, ctx) => await handler(evt, ctx), - Aggregate = true + Handler = async (evt, ctx) => await handler(evt, ctx) }); return this; } - public async Task Resolve(AppSyncEventsRequest appsyncEvent, ILambdaContext context) + public async Task Resolve(AppSyncEventsRequest appsyncEvent, ILambdaContext context) { if (IsPublishEvent(appsyncEvent)) { @@ -139,16 +136,15 @@ public async Task Resolve(AppSyncEventsRequest appsyncEve private async Task HandlePublishEvent(AppSyncEventsRequest appsyncEvent, ILambdaContext context) { - var channelPath = appsyncEvent.Info.Channel.Path; + 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 + var events = appsyncEvent.Events? .Select(e => new AppSyncEvent { Id = e.Id, @@ -166,61 +162,25 @@ private async Task HandlePublishEvent(AppSyncEventsReques { // Process entire event in one call var handlerResult = await handlerOptions.Handler(appsyncEvent, context); - - // Check if the handler returned a collection of events - if (handlerResult is Dictionary dict && - dict.TryGetValue("events", out var eventsObj) && - eventsObj is IEnumerable eventsList) - { - // Process each event in the collection - foreach (var eventObj in eventsList) - { - if (eventObj is Dictionary eventDict) - { - string eventId = null; - if (eventDict.TryGetValue("id", out var idObj) && idObj != null) - { - eventId = idObj.ToString(); - } - - if (eventDict.TryGetValue("error", out var errorObj) && errorObj != null) - { - // This is an error result - results.Add(new AppSyncEvent - { - Id = eventId, - Error = errorObj.ToString() - }); - } - else - { - // Remove id from payload if present - var payload = new Dictionary(eventDict); - if (payload.ContainsKey("id")) payload.Remove("id"); - - results.Add(new AppSyncEvent - { - Id = eventId, - Payload = payload - }); - } - } - } - } + if (handlerResult is AppSyncEventsResponse { Events: not null } result) + return result; + } + catch (UnauthorizedException) + { + throw; } catch (Exception ex) { - // results.Add(FormatErrorResponse(ex, null)); return new AppSyncEventsResponse { Error = ex.Message, - }; } } else { // Process each event individually + if (appsyncEvent.Events == null) return new AppSyncEventsResponse { Events = results }; foreach (var eventItem in appsyncEvent.Events) { try @@ -243,6 +203,10 @@ private async Task HandlePublishEvent(AppSyncEventsReques Error = error }); } + catch (UnauthorizedException) + { + throw; + } catch (Exception ex) { results.Add(FormatErrorResponse(ex, eventItem.Id)); diff --git a/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs index faa2dc36..56d7eb9b 100644 --- a/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs @@ -8,7 +8,7 @@ namespace AWS.Lambda.Powertools.EventHandler.Tests; public class AppSyncEventsTests { - private readonly AppSyncEventsRequest? _appSyncEvent; + private readonly AppSyncEventsRequest _appSyncEvent; public AppSyncEventsTests() { @@ -18,7 +18,7 @@ public AppSyncEventsTests() { PropertyNameCaseInsensitive = true, Converters = { new JsonStringEnumConverter() } - }); + })!; } [Fact] @@ -215,16 +215,14 @@ public async Task Should_Handle_Error_In_Aggregate_Mode() var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - app.OnPublish("/default/channel", - async (evt, ctx) => { throw new InvalidOperationException("Aggregate error"); }, aggregate: true); + app.OnPublishAggregate("/default/channel", + async (evt, ctx) => { throw new InvalidOperationException("Aggregate error"); }); // Act var result = await app.Resolve(_appSyncEvent, lambdaContext); // Assert - Assert.Single(result.Events); - Assert.NotNull(result.Events[0].Error); - Assert.Contains("Aggregate error", result.Events[0].Error); + Assert.Contains("Aggregate error", result.Error); } [Fact] @@ -370,10 +368,10 @@ public async Task Aggregate_Handler_Can_Return_Individual_Results_With_Ids() var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - app.OnPublish("/default/channel", async (evt) => + app.OnPublishAggregate("/default/channel", async (evt) => { // Iterate through events and return individual results with IDs - var results = new List>(); + var results = new List(); foreach (var eventItem in evt.Events) { @@ -382,35 +380,38 @@ public async Task Aggregate_Handler_Can_Return_Individual_Results_With_Ids() if (eventItem.Payload.ContainsKey("event_2")) { // Create an error for the second event - results.Add(new Dictionary + results.Add(new AppSyncEvent { - ["id"] = eventItem.Id, - ["error"] = "Intentional error for event 2" + Id = eventItem.Id, + Error = "Intentional error for event 2" }); } else { // Process normally - results.Add(new Dictionary + results.Add(new AppSyncEvent { - ["id"] = eventItem.Id, - ["processed"] = true, - ["originalData"] = eventItem.Payload + Id = eventItem.Id, + Payload = new Dictionary + { + ["processed"] = true, + ["originalData"] = eventItem.Payload + } }); } } catch (Exception ex) { - results.Add(new Dictionary + results.Add(new AppSyncEvent { - ["id"] = eventItem.Id, - ["error"] = $"{ex.GetType().Name} - {ex.Message}" + Id = eventItem.Id, + Error = $"{ex.GetType().Name} - {ex.Message}" }); } } - return new Dictionary { ["events"] = results }; - }, aggregate: true); + return new AppSyncEventsResponse { Events = results }; + }); // Act var result = await app.Resolve(_appSyncEvent, lambdaContext); @@ -693,4 +694,45 @@ public async Task Should_Return_UnauthorizedException_When_Throwing_Unauthorized await Assert.ThrowsAsync(() => app.Resolve(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.OnPublishAggregate("/default/channel", async (payload) => throw new UnauthorizedException("OOPS")); + } + else + { + app.OnPublish("/default/channel", async (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.Resolve(subscribeEvent, lambdaContext)); + } } \ No newline at end of file From 1bad95eb337749153d1e660f7cb1c87f0085329d Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Sat, 19 Apr 2025 19:28:49 +0100 Subject: [PATCH 06/16] feat: add JsonPropertyName attributes to AppSync event models for improved serialization --- .../AppSyncEvents/AppSyncEventsRequest.cs | 6 ++++++ .../AppSyncEvents/AppSyncEventsResponse.cs | 1 + .../AppSyncEvents/Channel.cs | 4 ++++ .../AppSyncEvents/ChannelNamespace.cs | 3 +++ .../AppSyncEvents/Information.cs | 2 ++ 5 files changed, 16 insertions(+) diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsRequest.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsRequest.cs index 289ec56e..46097d44 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsRequest.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsRequest.cs @@ -27,32 +27,38 @@ public class AppSyncEventsRequest /// /// 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; } /// diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResponse.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResponse.cs index 4619ee71..7069cba5 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResponse.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResponse.cs @@ -18,5 +18,6 @@ public class AppSyncEventsResponse /// 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/Channel.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Channel.cs index 817faf30..156c736a 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Channel.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Channel.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; /// @@ -8,10 +10,12 @@ 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 index 643d3f7f..9bcc5e6e 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/ChannelNamespace.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/ChannelNamespace.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace AWS.Lambda.Powertools.EventHandler.AppSyncEvents; /// @@ -8,5 +10,6 @@ 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 index a65af8d5..792e4241 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Information.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Information.cs @@ -10,6 +10,7 @@ public class Information /// /// The channel being used for the operation /// + [JsonPropertyName("channel")] public Channel Channel { get; set; } /// @@ -20,5 +21,6 @@ public class Information /// /// The operation being performed (e.g., Publish, Subscribe) /// + [JsonPropertyName("operation")] public AppSyncEventsOperation Operation { get; set; } } \ No newline at end of file From 8a537675c0e1827999fa161d7c9796963aa7f2f1 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Sat, 19 Apr 2025 19:40:14 +0100 Subject: [PATCH 07/16] initial docs --- docs/core/event_handler/appsync_events.md | 1 + .../README.md | 99 ++++++++++++++++++- mkdocs.yml | 1 + 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 docs/core/event_handler/appsync_events.md diff --git a/docs/core/event_handler/appsync_events.md b/docs/core/event_handler/appsync_events.md new file mode 100644 index 00000000..a52160c1 --- /dev/null +++ b/docs/core/event_handler/appsync_events.md @@ -0,0 +1 @@ +# AppSync Events diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/README.md b/libraries/src/AWS.Lambda.Powertools.EventHandler/README.md index c89d2af9..07f3637d 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/README.md +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/README.md @@ -1 +1,98 @@ -# AWS Lambda Powertools for .NET - Event Handler \ No newline at end of file +# AWS Lambda Powertools for .NET - Event Handler + +## AppSync Events + +### 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.Resolve(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.OnPublish("/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.OnPublishAggregate("/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.OnSubscribe("/default/*", async (payload) => +{ + Logger.LogInformation("Subscribed to /default/* with {@payload}", payload); + return await Task.FromResult(true); +}); + +async Task Handler(AppSyncEventsRequest appSyncEvent, ILambdaContext context) +{ + return await app.Resolve(appSyncEvent, context); +} + +await LambdaBootstrapBuilder.Create((Func>)Handler, +new DefaultLambdaJsonSerializer()) + .Build() + .RunAsync(); +``` \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index bd8426e8..5a8deacf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -21,6 +21,7 @@ nav: - core/metrics.md - core/metrics-v2.md - core/tracing.md + - core/event_handler/appsync_events.md - Utilities: - utilities/parameters.md - utilities/idempotency.md From 67717885f5509cd9477a73e73b167996223175d9 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Mon, 21 Apr 2025 18:36:39 +0100 Subject: [PATCH 08/16] feat: enhance AppSyncEventsResolver with async handlers. Add documentation. Fix nullabel fields --- docs/core/event_handler/appsync_events.md | 593 +++++++++++++++++- .../AppSyncEvents/AppSyncEventsResolver.cs | 398 +++++++++--- .../AppSyncEvents/AppSyncOidcIdentity.cs | 6 +- .../AppSyncEvents/Information.cs | 4 +- .../Internal/LRUCache.cs | 2 - .../Internal/RouteHandlerOptions.cs | 2 +- .../Internal/RouteHandlerRegistry.cs | 19 +- .../README.md | 27 +- .../AppSyncEventsTests.cs | 346 +++++++--- 9 files changed, 1190 insertions(+), 207 deletions(-) diff --git a/docs/core/event_handler/appsync_events.md b/docs/core/event_handler/appsync_events.md index a52160c1..741c1fbe 100644 --- a/docs/core/event_handler/appsync_events.md +++ b/docs/core/event_handler/appsync_events.md @@ -1 +1,592 @@ -# AppSync Events +--- +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 hl_lines="5 10 12" + 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 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: + +* **Error per item:** 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 + The event handler automatically parses the incoming event data and invokes the appropriate handler based on the namespace/channel pattern you register. + +You can define your handlers for different event types using the `OnPublish()`, `OnPublishAggregate()`, and `OnSubscribe()` methods and their `Async` versions. + +=== "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 multiple handlers could match the same event, 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()`, 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 throwing exceptions in your resolvers or by formatting the payload according to the expected response structure. This instructs AppSync not to propagate that specific message, so subscribers will not receive the corresponding message. + +#### Handling errors with individual items + +When processing items individually with `OnPublish()`, 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 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()`, 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 response" + + ```json + { + "error": "My custom exception" + } + ``` + +#### Rejecting the entire request + +??? warning "Raising `UnauthorizedException` will cause the Lambda invocation to fail." + +You can also reject the entire payload by raising an `UnauthorizedException`. This prevents Powertools from processing any messages and causes the Lambda invocation to fail, returning an error to AppSync. + +=== "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; + }); + ``` + +## 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/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs index 2479f0d9..5213f66f 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncEventsResolver.cs @@ -1,4 +1,3 @@ -using System.Text.RegularExpressions; using Amazon.Lambda.Core; using AWS.Lambda.Powertools.EventHandler.Internal; @@ -14,38 +13,195 @@ 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; + } + /// - /// Registers a handler for publish events on a specific channel path. - /// Processes each event in the payload individually. + /// 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. /// - public AppSyncEventsResolver OnPublish(string path, Func, Task> handler) + /// 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, ctx) => + Handler = async (evt, _) => { var payload = evt.Events?.FirstOrDefault()?.Payload; return await handler(payload ?? new Dictionary()); }, - Aggregate = false + Aggregate = aggregate }); - return this; } - /// - /// Registers a handler for publish events on a specific channel path. - /// Processes each event in the payload individually. - /// Lambda context available - /// - public AppSyncEventsResolver OnPublish(string path, - Func, ILambdaContext, Task> handler) + private void RegisterPublishHandler(string path, Func, ILambdaContext, Task> handler, bool aggregate) { _publishRoutes.Register(new RouteHandlerOptions { @@ -55,70 +211,134 @@ public AppSyncEventsResolver OnPublish(string path, var payload = evt.Events?.FirstOrDefault()?.Payload; return await handler(payload ?? new Dictionary(), ctx); }, - Aggregate = false + Aggregate = aggregate }); - return this; } - /// - /// Registers a handler for publish events on a specific channel path. - /// Processes all events in a single handler invocation. - /// - public AppSyncEventsResolver OnPublishAggregate(string path, Func> handler) + 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, ctx) => await handler(evt), + Handler = async (evt, _) => await handler(evt), Aggregate = true }); - return this; } - /// - /// Registers a handler for publish events on a specific channel path. - /// Processes all events in a single handler invocation. - /// Lambda context available - /// - public AppSyncEventsResolver OnPublishAggregate(string path, - Func> handler) + private void RegisterAggregateHandler(string path, Func> handler) { _publishRoutes.Register(new RouteHandlerOptions { Path = path, - Handler = async (evt, ctx) => (object)await handler(evt, ctx), + Handler = async (evt, ctx) => await handler(evt, ctx), Aggregate = true }); - return this; } - /// - /// Registers a handler for subscription events on a specific channel path. - /// - public AppSyncEventsResolver OnSubscribe(string path, Func> handler) + 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, ctx) => await handler(evt) + Handler = async (evt, _) => await handler(evt) }); - return this; } - /// - /// Registers a handler for subscription events on a specific channel path with Lambda context. - /// - public AppSyncEventsResolver OnSubscribe(string path, Func> handler) + private void RegisterSubscribeHandler(string path, Func> handler) { _subscribeRoutes.Register(new RouteHandlerOptions { Path = path, Handler = async (evt, ctx) => await handler(evt, ctx) }); - return this; } - public async Task Resolve(AppSyncEventsRequest appsyncEvent, ILambdaContext context) + 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)) { @@ -127,7 +347,7 @@ public AppSyncEventsResolver OnSubscribe(string path, Func HandlePublishEvent(AppSyncEventsRequest appsyncEvent, ILambdaContext context) { - var channelPath = appsyncEvent.Info?.Channel.Path; + 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 @@ -163,7 +383,15 @@ private async Task HandlePublishEvent(AppSyncEventsReques // 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) { @@ -173,7 +401,7 @@ private async Task HandlePublishEvent(AppSyncEventsReques { return new AppSyncEventsResponse { - Error = ex.Message, + Error = $"{ex.GetType().Name} - {ex.Message}" }; } } @@ -185,23 +413,30 @@ private async Task HandlePublishEvent(AppSyncEventsReques { try { - context.Logger.LogInformation($"Handling event item: {eventItem.Id}"); - // Create a copy of the event with just this single event - var singleEventCopy = new AppSyncEventsRequest - { - Info = appsyncEvent.Info, - Events = [eventItem] - }; + var result = await handlerOptions.Handler( + new AppSyncEventsRequest + { + Info = appsyncEvent.Info, + Events = [eventItem] + }, context); - var handlerResult = await handlerOptions.Handler(singleEventCopy, context); - var payload = ConvertToPayload(handlerResult, out var error); - - results.Add(new AppSyncEvent + var payload = ConvertToPayload(result, out var error); + if (error != null) + { + results.Add(new AppSyncEvent + { + Id = eventItem.Id, + Error = error + }); + } + else { - Id = eventItem.Id, - Payload = payload, - Error = error - }); + results.Add(new AppSyncEvent + { + Id = eventItem.Id, + Payload = payload + }); + } } catch (UnauthorizedException) { @@ -209,7 +444,7 @@ private async Task HandlePublishEvent(AppSyncEventsReques } catch (Exception ex) { - results.Add(FormatErrorResponse(ex, eventItem.Id)); + results.Add(FormatErrorResponse(ex, eventItem.Id!)); } } } @@ -218,17 +453,14 @@ private async Task HandlePublishEvent(AppSyncEventsReques } /// - /// Handles subscription events. - /// Null is successful, otherwise returns an error message. + /// 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]}"; + var channelPath = appsyncEvent.Info?.Channel?.Path; + var channelBase = $"/{appsyncEvent.Info?.Channel?.Segments?[0]}"; // Find matching subscribe handler var subscribeHandler = _subscribeRoutes.ResolveFirst(channelPath); @@ -238,10 +470,9 @@ private async Task HandlePublishEvent(AppSyncEventsReques } // Check if there's ANY publish handler for the base channel namespace - // This ensures we don't require exact path matches between publish and subscribe bool hasAnyPublishHandler = _publishRoutes.GetAllHandlers() .Any(h => h.Path.StartsWith(channelBase)); - + if (!hasAnyPublishHandler) { return null; @@ -250,8 +481,7 @@ private async Task HandlePublishEvent(AppSyncEventsReques try { var result = await subscribeHandler.Handler(appsyncEvent, context); - return !result ? - new AppSyncEventsResponse { Error = "Subscription failed" } : null; + return !result ? new AppSyncEventsResponse { Error = "Subscription failed" } : null; } catch (UnauthorizedException) { @@ -264,7 +494,7 @@ private async Task HandlePublishEvent(AppSyncEventsReques } } - private Dictionary ConvertToPayload(object result, out string error) + private Dictionary? ConvertToPayload(object result, out string? error) { error = null; @@ -288,24 +518,32 @@ private AppSyncEvent FormatErrorResponse(Exception ex, string id) { return new AppSyncEvent { - Id = id, // This will be the original event ID or null + Id = id, Error = $"{ex.GetType().Name} - {ex.Message}" }; } private bool IsPublishEvent(AppSyncEventsRequest appsyncEvent) { - return appsyncEvent.Info.Operation == AppSyncEventsOperation.Publish; + return appsyncEvent.Info?.Operation == AppSyncEventsOperation.Publish; } private bool IsSubscribeEvent(AppSyncEventsRequest appsyncEvent) { - return appsyncEvent.Info.Operation == AppSyncEventsOperation.Subscribe; + 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) { } diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncOidcIdentity.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncOidcIdentity.cs index 2708d3be..8d06db2e 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncOidcIdentity.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/AppSyncOidcIdentity.cs @@ -8,15 +8,15 @@ public class AppSyncOidcIdentity /// /// Claims from the OIDC token as key-value pairs /// - public Dictionary Claims { get; set; } + public Dictionary? Claims { get; set; } /// /// The issuer of the OIDC token /// - public string Issuer { get; set; } + public string? Issuer { get; set; } /// /// The UUID of the authenticated user /// - public string Sub { get; set; } + public string? Sub { 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 index 792e4241..79c62a05 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Information.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/AppSyncEvents/Information.cs @@ -11,12 +11,12 @@ public class Information /// The channel being used for the operation /// [JsonPropertyName("channel")] - public Channel Channel { get; set; } + public Channel? Channel { get; set; } /// /// The namespace of the channel /// - public ChannelNamespace ChannelNamespace { get; set; } + public ChannelNamespace? ChannelNamespace { get; set; } /// /// The operation being performed (e.g., Publish, Subscribe) diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs index 9f1972d1..25e2899c 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs @@ -1,5 +1,3 @@ -using System.Collections.Concurrent; - namespace AWS.Lambda.Powertools.EventHandler.Internal; /// diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerOptions.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerOptions.cs index e4c640d6..06cb2a2a 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerOptions.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerOptions.cs @@ -15,7 +15,7 @@ internal class RouteHandlerOptions /// /// The handler function to execute when path matches /// - public Func> Handler { get; set; } + public required Func> Handler { get; set; } /// /// Whether to aggregate all events into a single handler call diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerRegistry.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerRegistry.cs index 24ad8666..241f0e7c 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerRegistry.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerRegistry.cs @@ -1,5 +1,3 @@ -using System.Text.RegularExpressions; - namespace AWS.Lambda.Powertools.EventHandler.Internal; /// @@ -55,15 +53,15 @@ public void Register(RouteHandlerOptions options) /// /// The path to match against registered routes /// Most specific matching handler or null if no match - public RouteHandlerOptions? ResolveFirst(string path) + public RouteHandlerOptions? ResolveFirst(string? path) { - if (_resolverCache.TryGet(path, out var cachedHandler)) + if (path != null && _resolverCache.TryGet(path, out var cachedHandler)) { return cachedHandler; } // First try for exact match - if (_resolvers.TryGetValue(path, out var exactMatch)) + if (path != null && _resolvers.TryGetValue(path, out var exactMatch)) { _resolverCache.Set(path, exactMatch); return exactMatch; @@ -71,7 +69,7 @@ public void Register(RouteHandlerOptions options) // Then try wildcard matches, sorted by specificity (most segments first) var wildcardMatches = _resolvers.Keys - .Where(pattern => IsWildcardMatch(pattern, path)) + .Where(pattern => path != null && IsWildcardMatch(pattern, path)) .OrderByDescending(pattern => pattern.Count(c => c == '/')) .ThenByDescending(pattern => pattern.Length); @@ -80,7 +78,7 @@ public void Register(RouteHandlerOptions options) if (bestMatch != null) { var handler = _resolvers[bestMatch]; - _resolverCache.Set(path, handler); + if (path != null) _resolverCache.Set(path, handler); return handler; } @@ -137,10 +135,7 @@ private bool IsWildcardMatch(string pattern, string path) private void LogWarning(string message) { - if (!_warnedPaths.Contains(message)) - { - _warnedPaths.Add(message); - Console.WriteLine($"Warning: {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/README.md b/libraries/src/AWS.Lambda.Powertools.EventHandler/README.md index 07f3637d..8c5002b9 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/README.md +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/README.md @@ -1,6 +1,19 @@ # AWS Lambda Powertools for .NET - Event Handler -## AppSync Events +## 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 @@ -19,7 +32,7 @@ using AWS.Lambda.Powertools.EventHandler; ```csharp async Task Handler(AppSyncEventsRequest appSyncEvent, ILambdaContext context) { - return await app.Resolve(appSyncEvent, context); + return await app.ResolveAsync(appSyncEvent, context); } ``` @@ -35,7 +48,7 @@ using AWS.Lambda.Powertools.Logging; var app = new AppSyncEventsResolver(); -app.OnPublish("/default/channel", async (payload) => +app.OnPublishAsync("/default/channel", async (payload) => { Logger.LogInformation("Published to /default/channel with {@payload}", payload); @@ -47,7 +60,7 @@ app.OnPublish("/default/channel", async (payload) => return "Hello from /default/channel"; }); -app.OnPublishAggregate("/default/channel2", async (payload) => +app.OnPublishAggregateAsync("/default/channel2", async (payload) => { var evt = new List(); foreach (var item in payload.Events) @@ -80,15 +93,15 @@ app.OnPublishAggregate("/default/channel2", async (payload) => }; }); -app.OnSubscribe("/default/*", async (payload) => +app.OnSubscribeAsync("/default/*", async (payload) => { Logger.LogInformation("Subscribed to /default/* with {@payload}", payload); - return await Task.FromResult(true); + return true; }); async Task Handler(AppSyncEventsRequest appSyncEvent, ILambdaContext context) { - return await app.Resolve(appSyncEvent, context); + return await app.ResolveAsync(appSyncEvent, context); } await LambdaBootstrapBuilder.Create((Func>)Handler, diff --git a/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs index 56d7eb9b..b4301f93 100644 --- a/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/AppSyncEventsTests.cs @@ -22,15 +22,14 @@ public AppSyncEventsTests() } [Fact] - public async Task Should_Return_Unchanged_Payload_No_Handlers() + public void Should_Return_Unchanged_Payload_No_Handlers() { // Arrange var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); // Act - var result = - await app.Resolve(_appSyncEvent, lambdaContext); + var result = app.Resolve(_appSyncEvent, lambdaContext); // Assert Assert.Equal(3, result.Events.Count); @@ -43,13 +42,39 @@ public async Task Should_Return_Unchanged_Payload_No_Handlers() } [Fact] - public async Task Should_Return_Unchanged_Payload() + public void Should_Return_Unchanged_Payload() { // Arrange var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - app.OnPublish("/default/channel", async (Dictionary payload) => + 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; @@ -57,7 +82,7 @@ public async Task Should_Return_Unchanged_Payload() // Act var result = - await app.Resolve(_appSyncEvent, lambdaContext); + await app.ResolveAsync(_appSyncEvent, lambdaContext); // Assert Assert.Equal(3, result.Events.Count); @@ -76,7 +101,7 @@ public async Task Should_Handle_Error_In_Event_Processing() var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - app.OnPublish("/default/channel", async (payload) => + app.OnPublishAsync("/default/channel", async (payload) => { // Throw exception for second event if (payload.ContainsKey("event_2")) @@ -88,17 +113,20 @@ public async Task Should_Handle_Error_In_Event_Processing() }); // Act - var result = await app.Resolve(_appSyncEvent, lambdaContext); + 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.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()); + 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] @@ -109,19 +137,22 @@ public async Task Should_Match_Path_With_Wildcard() var app = new AppSyncEventsResolver(); int callCount = 0; - app.OnPublish("/default/*", async (payload) => + app.OnPublishAsync("/default/*", async (payload) => { callCount++; return new Dictionary { ["wildcard_matched"] = true }; }); // Act - var result = await app.Resolve(_appSyncEvent, lambdaContext); + var result = await app.ResolveAsync(_appSyncEvent, lambdaContext); // Assert - Assert.Equal(3, result.Events.Count); - Assert.Equal(3, callCount); - Assert.True((bool)result.Events[0].Payload["wildcard_matched"]); + 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] @@ -131,9 +162,9 @@ public async Task Should_Authorize_Subscription() var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - app.OnPublish("/default/channel", async (payload) => payload); + app.OnPublishAsync("/default/channel", async (payload) => payload); - app.OnSubscribe("/default/*", async (info) => true); + app.OnSubscribeAsync("/default/*", async (info) => true); var subscribeEvent = new AppSyncEventsRequest { Info = new Information @@ -148,22 +179,22 @@ public async Task Should_Authorize_Subscription() } }; // Act - var result = await app.Resolve(subscribeEvent, lambdaContext); + var result = await app.ResolveAsync(subscribeEvent, lambdaContext); // Assert Assert.Null(result); } [Fact] - public async Task Should_Deny_Subscription() + public void Should_Deny_Subscription() { // Arrange var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - app.OnPublish("/default/channel", async (payload) => payload); + app.OnPublish("/default/channel", (payload) => payload); - app.OnSubscribe("/default/*", async (info) => false); + app.OnSubscribe("/default/*", (info) => false); var subscribeEvent = new AppSyncEventsRequest { Info = new Information @@ -174,22 +205,22 @@ public async Task Should_Deny_Subscription() } }; // Act - var result = await app.Resolve(subscribeEvent, lambdaContext); + var result = app.Resolve(subscribeEvent, lambdaContext); // Assert Assert.NotNull(result.Error); } [Fact] - public async Task Should_Deny_Subscription_On_Exception() + public void Should_Deny_Subscription_On_Exception() { // Arrange var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - app.OnPublish("/default/channel", async (payload) => payload); + app.OnPublish("/default/channel", (payload) => payload); - app.OnSubscribe("/default/*", async (info) => { throw new Exception("Authorization error"); }); + app.OnSubscribe("/default/*", (info) => { throw new Exception("Authorization error"); }); var subscribeEvent = new AppSyncEventsRequest { @@ -202,37 +233,54 @@ public async Task Should_Deny_Subscription_On_Exception() }; // Act - var result = await app.Resolve(subscribeEvent, lambdaContext); + var result = app.Resolve(subscribeEvent, lambdaContext); // Assert Assert.Equal("Authorization error", result.Error); } [Fact] - public async Task Should_Handle_Error_In_Aggregate_Mode() + 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.Resolve(_appSyncEvent, lambdaContext); + var result = await app.ResolveAsync(_appSyncEvent, lambdaContext); // Assert Assert.Contains("Aggregate error", result.Error); } [Fact] - public async Task Should_Handle_TransformingPayload() + public void Should_Handle_TransformingPayload() { // Arrange var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - app.OnPublish("/default/channel", async (payload) => + app.OnPublish("/default/channel", (payload) => { // Transform each event payload var transformedPayload = new Dictionary(); @@ -245,16 +293,50 @@ public async Task Should_Handle_TransformingPayload() }); // Act - var result = await app.Resolve(_appSyncEvent, lambdaContext); + var result = app.Resolve(_appSyncEvent, lambdaContext); // Assert - 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()); + 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() + public async Task Should_Throw_For_Unknown_EventType_Async() { // Arrange var lambdaContext = new TestLambdaContext(); @@ -272,32 +354,57 @@ public async Task Should_Throw_For_Unknown_EventType() // 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 async Task Should_Return_NonDictionary_Values_Wrapped_In_Data() + public void Should_Return_NonDictionary_Values_Wrapped_In_Data() { // Arrange var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - app.OnPublish("/default/channel", async (payload) => + app.OnPublish("/default/channel", (payload) => { // Return a non-dictionary value return "string value"; }); // Act - var result = await app.Resolve(_appSyncEvent, lambdaContext); + var result = app.Resolve(_appSyncEvent, lambdaContext); // Assert - Assert.Equal(3, result.Events.Count); - Assert.Equal("string value", result.Events[0].Payload["data"].ToString()); + if (result.Events != null) + { + Assert.Equal(3, result.Events.Count); + Assert.Equal("string value", result.Events[0].Payload?["data"].ToString()); + } } [Fact] - public async Task Should_Skip_Invalid_Path_Registration() + public void Should_Skip_Invalid_Path_Registration() { // Arrange var lambdaContext = new TestLambdaContext(); @@ -305,60 +412,94 @@ public async Task Should_Skip_Invalid_Path_Registration() var handlerCalled = false; // Register with invalid path - app.OnPublish("/invalid/*/path", async (payload) => + app.OnPublish("/invalid/*/path", (payload) => { handlerCalled = true; return payload; }); // Act - var result = await app.Resolve(_appSyncEvent, lambdaContext); + var result = app.Resolve(_appSyncEvent, lambdaContext); // Assert - Should return original payload, handler not called - Assert.Equal(3, result.Events.Count); - Assert.Equal("data_1", result.Events[0].Payload["event_1"].ToString()); + 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 async Task Should_Replace_Handler_When_RegisteringTwice() + public void Should_Replace_Handler_When_RegisteringTwice() { // Arrange var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); app.OnPublish("/default/channel", - async (payload) => { return new Dictionary { ["handler"] = "first" }; }); + (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.Resolve(_appSyncEvent, lambdaContext); + var result = await app.ResolveAsync(_appSyncEvent, lambdaContext); // Assert - Only second handler should be used - Assert.Equal(3, result.Events.Count); - Assert.Equal("second", result.Events[0].Payload["handler"].ToString()); + if (result.Events != null) + { + Assert.Equal(3, result.Events.Count); + Assert.Equal("second", result.Events[0].Payload?["handler"].ToString()); + } } [Fact] - public async Task Should_Maintain_EventIds_When_Processing() + public void Should_Maintain_EventIds_When_Processing() { // Arrange var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); app.OnPublish("/default/channel", - async (payload) => { return new Dictionary { ["processed"] = true }; }); + (payload) => { return new Dictionary { ["processed"] = true }; }); // Act - var result = await app.Resolve(_appSyncEvent, lambdaContext); + var result = app.Resolve(_appSyncEvent, lambdaContext); // Assert - 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); + 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] @@ -368,7 +509,11 @@ public async Task Aggregate_Handler_Can_Return_Individual_Results_With_Ids() var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - app.OnPublishAggregate("/default/channel", async (evt) => + 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(); @@ -414,17 +559,20 @@ public async Task Aggregate_Handler_Can_Return_Individual_Results_With_Ids() }); // Act - var result = await app.Resolve(_appSyncEvent, lambdaContext); + var result = await app.ResolveAsync(_appSyncEvent, lambdaContext); // Assert - Assert.Equal(3, result.Events.Count); - Assert.Equal("1", result.Events[0].Id); - Assert.True((bool)result.Events[0].Payload["processed"]); - 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"]); + 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] @@ -435,7 +583,7 @@ public async Task Should_Verify_Ids_Are_Preserved_In_Error_Case() var app = new AppSyncEventsResolver(); // Create handlers that throw exceptions for specific events - app.OnPublish("/default/channel", async (payload) => + app.OnPublishAsync("/default/channel", async (payload) => { if (payload.ContainsKey("event_1")) throw new InvalidOperationException("Error for event 1"); @@ -445,7 +593,7 @@ public async Task Should_Verify_Ids_Are_Preserved_In_Error_Case() }); // Act - var result = await app.Resolve(_appSyncEvent, lambdaContext); + var result = await app.ResolveAsync(_appSyncEvent, lambdaContext); // Assert Assert.Equal(3, result.Events.Count); @@ -467,20 +615,20 @@ public async Task Should_Match_Most_Specific_Handler_Only() int firstHandlerCalls = 0; int secondHandlerCalls = 0; - app.OnPublish("/default/channel", async (payload) => + app.OnPublishAsync("/default/channel", async (payload) => { firstHandlerCalls++; return new Dictionary { ["handler"] = "first" }; }); - app.OnPublish("/default/*", async (payload) => + app.OnPublishAsync("/default/*", async (payload) => { secondHandlerCalls++; return new Dictionary { ["handler"] = "second" }; }); // Act - var result = await app.Resolve(_appSyncEvent, lambdaContext); + var result = await app.ResolveAsync(_appSyncEvent, lambdaContext); // Assert - Only the first (most specific) handler should be called Assert.Equal(3, result.Events.Count); @@ -519,7 +667,7 @@ public async Task Should_Handle_Multiple_Keys_In_Payload() ] }; - app.OnPublish("/default/channel", async (payload) => + app.OnPublishAsync("/default/channel", async (payload) => { // Check that both keys are present Assert.Equal("data_1", payload["event_1"]); @@ -534,7 +682,7 @@ public async Task Should_Handle_Multiple_Keys_In_Payload() }); // Act - var result = await app.Resolve(multiKeyEvent, lambdaContext); + var result = await app.ResolveAsync(multiKeyEvent, lambdaContext); // Assert Assert.Single(result.Events); @@ -551,17 +699,17 @@ public async Task Should_Only_Use_First_Matching_Handler_By_Specificity() var app = new AppSyncEventsResolver(); // Register handlers with different specificity - app.OnPublish("/*", async (payload) => + app.OnPublishAsync("/*", async (payload) => new Dictionary { ["handler"] = "least-specific" }); - app.OnPublish("/default/*", async (payload) => + app.OnPublishAsync("/default/*", async (payload) => new Dictionary { ["handler"] = "more-specific" }); - app.OnPublish("/default/channel", async (payload) => + app.OnPublishAsync("/default/channel", async (payload) => new Dictionary { ["handler"] = "most-specific" }); // Act - var result = await app.Resolve(_appSyncEvent, lambdaContext); + var result = await app.ResolveAsync(_appSyncEvent, lambdaContext); // Assert - Only the most specific handler should be called Assert.Equal(3, result.Events.Count); @@ -596,11 +744,11 @@ public async Task Should_Fallback_To_Less_Specific_Handler_If_No_Exact_Match() ] }; - app.OnPublish("/default/*", async (payload) => + app.OnPublishAsync("/default/*", async (payload) => new Dictionary { ["handler"] = "wildcard-handler" }); // Act - var result = await app.Resolve(fallbackEvent, lambdaContext); + var result = await app.ResolveAsync(fallbackEvent, lambdaContext); // Assert Assert.Single(result.Events); @@ -615,7 +763,7 @@ public async Task Should_Return_Null_When_Subscribing_To_Path_Without_Publish_Ha var app = new AppSyncEventsResolver(); // Only set up a subscribe handler without corresponding publish handler - app.OnSubscribe("/subscribe-only", async (info) => true); + app.OnSubscribeAsync("/subscribe-only", async (info) => true); var subscribeEvent = new AppSyncEventsRequest { @@ -628,7 +776,7 @@ public async Task Should_Return_Null_When_Subscribing_To_Path_Without_Publish_Ha }; // Act - var result = await app.Resolve(subscribeEvent, lambdaContext); + var result = await app.ResolveAsync(subscribeEvent, lambdaContext); // Assert Assert.Null(result); @@ -637,15 +785,15 @@ public async Task Should_Return_Null_When_Subscribing_To_Path_Without_Publish_Ha [Theory] [InlineData("/default/channel", "/default/channel1")] [InlineData("/default/channel3", "/default/channel")] - public async Task Should_Return_Null_When_Subscribing_To_Path_With_No_Match_Publish_Handler(string publishPath, + 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, async (payload) => payload); - app.OnSubscribe(subscribePath, async (info) => true); + app.OnPublish(publishPath, (payload) => payload); + app.OnSubscribe(subscribePath, (info) => true); var subscribeEvent = new AppSyncEventsRequest { @@ -658,7 +806,7 @@ public async Task Should_Return_Null_When_Subscribing_To_Path_With_No_Match_Publ }; // Act - var result = await app.Resolve(subscribeEvent, lambdaContext); + var result = app.Resolve(subscribeEvent, lambdaContext); // Assert Assert.Null(result); @@ -676,9 +824,9 @@ public async Task Should_Return_UnauthorizedException_When_Throwing_Unauthorized var lambdaContext = new TestLambdaContext(); var app = new AppSyncEventsResolver(); - app.OnPublish(publishPath, async (payload) => payload); - app.OnSubscribe(subscribePath, - async (info, lambdaContext) => { throw new UnauthorizedException("OOPS"); }); + app.OnPublishAsync(publishPath, async (payload) => payload); + app.OnSubscribeAsync(subscribePath, + (info, lambdaContext) => { throw new UnauthorizedException("OOPS"); }); var subscribeEvent = new AppSyncEventsRequest { @@ -692,7 +840,7 @@ public async Task Should_Return_UnauthorizedException_When_Throwing_Unauthorized // Act && Assert await Assert.ThrowsAsync(() => - app.Resolve(subscribeEvent, lambdaContext)); + app.ResolveAsync(subscribeEvent, lambdaContext)); } [Theory] @@ -706,11 +854,11 @@ public async Task Should_Return_UnauthorizedException_When_Throwing_Unauthorized if (aggreate) { - app.OnPublishAggregate("/default/channel", async (payload) => throw new UnauthorizedException("OOPS")); + app.OnPublishAggregateAsync("/default/channel", (payload) => throw new UnauthorizedException("OOPS")); } else { - app.OnPublish("/default/channel", async (payload) => throw new UnauthorizedException("OOPS")); + app.OnPublishAsync("/default/channel", (payload) => throw new UnauthorizedException("OOPS")); } var subscribeEvent = new AppSyncEventsRequest @@ -733,6 +881,6 @@ public async Task Should_Return_UnauthorizedException_When_Throwing_Unauthorized // Act && Assert await Assert.ThrowsAsync(() => - app.Resolve(subscribeEvent, lambdaContext)); + app.ResolveAsync(subscribeEvent, lambdaContext)); } } \ No newline at end of file From 4b931a7dde65ea9a88ab7ce0daf8d9800cc51fb7 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Mon, 21 Apr 2025 18:40:59 +0100 Subject: [PATCH 09/16] doc updates --- docs/core/event_handler/appsync_events.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/core/event_handler/appsync_events.md b/docs/core/event_handler/appsync_events.md index 741c1fbe..60e2a47b 100644 --- a/docs/core/event_handler/appsync_events.md +++ b/docs/core/event_handler/appsync_events.md @@ -91,7 +91,7 @@ You must have an existing AppSync Events API with real-time capabilities enabled ### AppSync request and response format -AppSync Events uses a specific event format for Lambda requests and responses. In most scenarios, Powertools simplifies this interaction by automatically formatting resolver returns to match the expected AppSync response structure. +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" @@ -214,7 +214,7 @@ AppSync Events uses a specific event format for Lambda requests and responses. I When processing events with Lambda, you can return errors to AppSync in three ways: -* **Error per item:** Return an `error` key within each individual item's response. AppSync Events expects this format for item-specific errors. +* **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. @@ -295,7 +295,7 @@ You can define your handlers for different event types using the `OnPublish()`, 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 multiple handlers could match the same event, the most specific pattern takes precedence. +When an event matches with multiple handlers, the most specific pattern takes precedence. === "Wildcard patterns" @@ -372,7 +372,7 @@ In some scenarios, you might want to process all events for a channel as a batch ### Handling errors -You can filter or reject events by throwing exceptions in your resolvers or by formatting the payload according to the expected response structure. This instructs AppSync not to propagate that specific message, so subscribers will not receive the corresponding message. +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 From c8db8233709de7831a6365d094272b4629a23e12 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Mon, 21 Apr 2025 18:42:43 +0100 Subject: [PATCH 10/16] doc update --- docs/core/event_handler/appsync_events.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/core/event_handler/appsync_events.md b/docs/core/event_handler/appsync_events.md index 60e2a47b..ff337910 100644 --- a/docs/core/event_handler/appsync_events.md +++ b/docs/core/event_handler/appsync_events.md @@ -431,7 +431,7 @@ When processing batch of items with `OnPublishAggregate()`, you must format the ??? warning "Raising `UnauthorizedException` will cause the Lambda invocation to fail." -You can also reject the entire payload by raising an `UnauthorizedException`. This prevents Powertools from processing any messages and causes the Lambda invocation to fail, returning an error to AppSync. +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. === "Rejecting the entire request" From 6973d36d3f7391f0ec85518760e4e00f9cd9cfc7 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Mon, 21 Apr 2025 19:06:30 +0100 Subject: [PATCH 11/16] doc updates --- docs/core/event_handler/appsync_events.md | 38 +++++++++++++++++------ 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/docs/core/event_handler/appsync_events.md b/docs/core/event_handler/appsync_events.md index ff337910..bbd1a66d 100644 --- a/docs/core/event_handler/appsync_events.md +++ b/docs/core/event_handler/appsync_events.md @@ -59,7 +59,7 @@ You must have an existing AppSync Events API with real-time capabilities enabled === "Getting started with AppSync Events" - ```yaml hl_lines="5 10 12" + ```yaml Resources: WebsocketAPI: Type: AWS::AppSync::Api @@ -95,7 +95,7 @@ AppSync Events uses a specific event format for Lambda requests and responses. I === "AppSync payload request" - ```json" + ```json { "identity":"None", "result":"None", @@ -171,7 +171,7 @@ AppSync Events uses a specific event format for Lambda requests and responses. I === "AppSync payload response" - ```json" + ```json { "events":[ { @@ -191,9 +191,9 @@ AppSync Events uses a specific event format for Lambda requests and responses. I ``` -=== "AppSync payload response with error +=== "AppSync payload response with error" - ```json" + ```json { "events":[ { @@ -221,9 +221,9 @@ When processing events with Lambda, you can return errors to AppSync in three wa ### Resolver ???+ important - The event handler automatically parses the incoming event data and invokes the appropriate handler based on the namespace/channel pattern you register. + 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. + 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" @@ -334,7 +334,7 @@ When an event matches with multiple handlers, the most specific pattern takes pr ### Aggregated processing ???+ note "Aggregate Processing" - `OnPublishAggregate()`, 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). + `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: @@ -376,7 +376,7 @@ You can filter or reject events by raising exceptions in your resolvers or by fo #### Handling errors with individual items -When processing items individually with `OnPublish()`, 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. +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" @@ -387,6 +387,15 @@ When processing items individually with `OnPublish()`, you can raise an exceptio }); ``` +=== "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" @@ -408,7 +417,7 @@ When processing items individually with `OnPublish()`, you can raise an exceptio #### Handling errors with batch of items -When processing batch of items with `OnPublishAggregate()`, you must format the payload according the expected response. +When processing batch of items with `OnPublishAggregate()` and `OnPublishAggregateAsync()`, you must format the payload according the expected response. === "Error handling batch items" @@ -419,6 +428,15 @@ When processing batch of items with `OnPublishAggregate()`, you must format the }); ``` +=== "Error handling batch items Async" + + ```csharp + app.OnPublishAggregateAsync("/default/channel", async (payload) => + { + throw new Exception("My custom exception"); + }); + ``` + === "Error handling batch items response" ```json From 9d05ee8b8c93ceae77942d0e7aabb487526e7ba3 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Mon, 21 Apr 2025 19:16:59 +0100 Subject: [PATCH 12/16] update mkdocs, event handler menu --- mkdocs.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 5a8deacf..ec2179c0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -21,7 +21,8 @@ nav: - core/metrics.md - core/metrics-v2.md - core/tracing.md - - core/event_handler/appsync_events.md + - Event Handler: + - core/event_handler/appsync_events.md - Utilities: - utilities/parameters.md - utilities/idempotency.md From ef36fcc9b749739c127a0ff60c3778498ada4e6b Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Tue, 22 Apr 2025 09:45:20 +0100 Subject: [PATCH 13/16] update docs --- docs/core/event_handler/appsync_events.md | 106 +++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/docs/core/event_handler/appsync_events.md b/docs/core/event_handler/appsync_events.md index bbd1a66d..75ac6410 100644 --- a/docs/core/event_handler/appsync_events.md +++ b/docs/core/event_handler/appsync_events.md @@ -445,12 +445,15 @@ When processing batch of items with `OnPublishAggregate()` and `OnPublishAggrega } ``` -#### Rejecting the entire request +#### 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 @@ -474,6 +477,107 @@ You can access to the original Lambda event or context for additional informatio }); ``` +## 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. From 95408ffd3799aaadff9a33b3ae90de3a911dc960 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Thu, 24 Apr 2025 21:29:51 +0100 Subject: [PATCH 14/16] add version --- version.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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" } } From 634eb6a7815d4664196bb9f34c80814039310e26 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Thu, 24 Apr 2025 21:37:47 +0100 Subject: [PATCH 15/16] fix project --- .../AWS.Lambda.Powertools.EventHandler.Tests.csproj | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index 750ab553..eef47181 100644 --- 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 @@ -1,7 +1,11 @@ + + - net8.0 + AWS.Lambda.Powertools.EventHandler.Tests + AWS.Lambda.Powertools.EventHandler.Tests + net8.0 enable enable From 94fdcc2ddd7b891d262fa010343c1f500f4d2f93 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Thu, 24 Apr 2025 21:40:21 +0100 Subject: [PATCH 16/16] fix sonar --- .../AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs | 4 ++-- .../Internal/RouteHandlerRegistry.cs | 6 +++--- .../RouteHandlerRegistryTests.cs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs index 25e2899c..37fa4663 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/LRUCache.cs @@ -6,7 +6,7 @@ namespace AWS.Lambda.Powertools.EventHandler.Internal; /// /// Simple LRU cache implementation for caching route resolutions /// -internal class LRUCache where TKey : notnull +internal class LruCache where TKey : notnull { private readonly int _capacity; private readonly Dictionary> _cache; @@ -24,7 +24,7 @@ public CacheItem(TKey key, TValue value) } } - public LRUCache(int capacity) + public LruCache(int capacity) { _capacity = capacity; _cache = new Dictionary>(); diff --git a/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerRegistry.cs b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerRegistry.cs index 241f0e7c..78c8ffe2 100644 --- a/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerRegistry.cs +++ b/libraries/src/AWS.Lambda.Powertools.EventHandler/Internal/RouteHandlerRegistry.cs @@ -14,7 +14,7 @@ internal class RouteHandlerRegistry /// /// Cache for resolved routes to improve performance /// - private readonly LRUCache> _resolverCache; + private readonly LruCache> _resolverCache; /// /// Set to track already logged warnings @@ -27,7 +27,7 @@ internal class RouteHandlerRegistry /// Max size of LRU cache (default 100) public RouteHandlerRegistry(int cacheSize = 100) { - _resolverCache = new LRUCache>(cacheSize); + _resolverCache = new LruCache>(cacheSize); } /// @@ -98,7 +98,7 @@ public IEnumerable> GetAllHandlers() /// private static bool IsValidPath(string path) { - if (string.IsNullOrWhiteSpace(path) || !path.StartsWith("/")) + if (string.IsNullOrWhiteSpace(path) || !path.StartsWith('/')) return false; // Check for invalid wildcard usage diff --git a/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/RouteHandlerRegistryTests.cs b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/RouteHandlerRegistryTests.cs index 06181944..92c9da3a 100644 --- a/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/RouteHandlerRegistryTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.EventHandler.Tests/RouteHandlerRegistryTests.cs @@ -179,7 +179,7 @@ public void ResolveFirst_ShouldUseCacheForRepeatedPaths() public void LRUCache_ShouldEvictOldestItemsWhenFull() { // Arrange - Create a cache with size 2 - var cache = new LRUCache(2); + var cache = new LruCache(2); // Act cache.Set("key1", "value1");