From 39d776c64040811979299a17fa2683ff07cf7cd3 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Mon, 13 Apr 2026 17:24:25 +0000 Subject: [PATCH] Add [DynamoDBEvent] annotation attribute and source generator support - DynamoDBEventAttribute with Stream, ResourceName, BatchSize, StartingPosition, MaximumBatchingWindowInSeconds, Filters, Enabled - DynamoDBEventAttributeBuilder for Roslyn AttributeData parsing - Source generator wiring (TypeFullNames, SyntaxReceiver, EventTypeBuilder, AttributeModelBuilder) - CloudFormationWriter ProcessDynamoDBAttribute (SAM DynamoDB event source mapping) - LambdaFunctionValidator ValidateDynamoDBEvents - DiagnosticDescriptors InvalidDynamoDBEventAttribute (AWSLambda0132) - DynamoDBEventAttributeTests (attribute unit tests) - DynamoDBEventsTests (CloudFormation writer tests) - E2E source generator snapshot tests - Integration test (DynamoDBEventSourceMapping) - Sample function (DynamoDbStreamProcessing) - .autover change file - README documentation --- .../changes/add-dynamodbevent-annotation.json | 11 + .../Diagnostics/AnalyzerReleases.Unshipped.md | 1 + .../Diagnostics/DiagnosticDescriptors.cs | 7 + .../Attributes/AttributeModelBuilder.cs | 10 + .../DynamoDBEventAttributeBuilder.cs | 52 +++ .../Models/EventTypeBuilder.cs | 4 + .../SyntaxReceiver.cs | 3 +- .../TypeFullNames.cs | 6 +- .../Validation/LambdaFunctionValidator.cs | 51 +++ .../Writers/CloudFormationWriter.cs | 67 ++++ .../DynamoDB/DynamoDBEventAttribute.cs | 141 ++++++++ .../src/Amazon.Lambda.Annotations/README.md | 24 ++ ....Annotations.SourceGenerators.Tests.csproj | 1 + .../CSharpSourceGeneratorVerifier.cs | 2 + .../DynamoDBEventAttributeTests.cs | 304 ++++++++++++++++++ ...Events_ProcessMessagesAsync_Generated.g.cs | 57 ++++ ...amoDBEvents_ProcessMessages_Generated.g.cs | 57 ++++ .../dynamoDBEvents.template | 129 ++++++++ .../SourceGeneratorTests.cs | 47 +++ .../WriterTests/DynamoDBEventsTests.cs | 234 ++++++++++++++ .../DynamoDBEventSourceMapping.cs | 38 +++ .../IntegrationTestContextFixture.cs | 15 +- .../TestServerlessApp.IntegrationTests.csproj | 1 + .../ValidDynamoDBEvents.cs.txt | 30 ++ .../DynamoDbStreamProcessing.cs | 17 + .../TestServerlessApp.csproj | 1 + .../TestServerlessApp/serverless.template | 21 ++ 27 files changed, 1328 insertions(+), 3 deletions(-) create mode 100644 .autover/changes/add-dynamodbevent-annotation.json create mode 100644 Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DynamoDBEventAttributeBuilder.cs create mode 100644 Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DynamoDBEventAttributeTests.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/DynamoDB/ValidDynamoDBEvents_ProcessMessagesAsync_Generated.g.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/DynamoDB/ValidDynamoDBEvents_ProcessMessages_Generated.g.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/dynamoDBEvents.template create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/DynamoDBEventsTests.cs create mode 100644 Libraries/test/TestServerlessApp.IntegrationTests/DynamoDBEventSourceMapping.cs create mode 100644 Libraries/test/TestServerlessApp/DynamoDBEventExamples/ValidDynamoDBEvents.cs.txt create mode 100644 Libraries/test/TestServerlessApp/DynamoDbStreamProcessing.cs diff --git a/.autover/changes/add-dynamodbevent-annotation.json b/.autover/changes/add-dynamodbevent-annotation.json new file mode 100644 index 000000000..7a11ec17f --- /dev/null +++ b/.autover/changes/add-dynamodbevent-annotation.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.Annotations", + "Type": "Minor", + "ChangelogMessages": [ + "Added [DynamoDBEvent] annotation attribute for declaratively configuring DynamoDB stream-triggered Lambda functions with support for stream reference, batch size, starting position, batching window, filters, and enabled state." + ] + } + ] +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md index d1a9a89d0..f26924d8c 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md @@ -21,3 +21,4 @@ AWSLambda0133 | AWSLambdaCSharpGenerator | Error | ALB Listener Reference Not Fo AWSLambda0134 | AWSLambdaCSharpGenerator | Error | FromRoute not supported on ALB functions AWSLambda0135 | AWSLambdaCSharpGenerator | Error | Unmapped parameter on ALB function AWSLambda0136 | AWSLambdaCSharpGenerator | Error | Invalid S3EventAttribute +AWSLambda0137 | AWSLambdaCSharpGenerator | Error | Invalid DynamoDBEventAttribute diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs index e1a11087f..fe85e4710 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs @@ -281,5 +281,12 @@ public static class DiagnosticDescriptors category: "AWSLambdaCSharpGenerator", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor InvalidDynamoDBEventAttribute = new DiagnosticDescriptor(id: "AWSLambda0137", + title: "Invalid DynamoDBEventAttribute", + messageFormat: "Invalid DynamoDBEventAttribute encountered: {0}", + category: "AWSLambdaCSharpGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true); } } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs index 0d1067bb6..1a8039ef8 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs @@ -1,5 +1,6 @@ using System; using Amazon.Lambda.Annotations.ALB; +using Amazon.Lambda.Annotations.DynamoDB; using Amazon.Lambda.Annotations.APIGateway; using Amazon.Lambda.Annotations.S3; using Amazon.Lambda.Annotations.SQS; @@ -101,6 +102,15 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext Type = TypeModelBuilder.Build(att.AttributeClass, context) }; } + else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.DynamoDBEventAttribute), SymbolEqualityComparer.Default)) + { + var data = DynamoDBEventAttributeBuilder.Build(att); + model = new AttributeModel + { + Data = data, + Type = TypeModelBuilder.Build(att.AttributeClass, context) + }; + } else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.HttpApiAuthorizerAttribute), SymbolEqualityComparer.Default)) { var data = HttpApiAuthorizerAttributeBuilder.Build(att); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DynamoDBEventAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DynamoDBEventAttributeBuilder.cs new file mode 100644 index 000000000..ae3affa94 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DynamoDBEventAttributeBuilder.cs @@ -0,0 +1,52 @@ +using Amazon.Lambda.Annotations.DynamoDB; +using Microsoft.CodeAnalysis; +using System; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes +{ + /// + /// Builder for . + /// + public class DynamoDBEventAttributeBuilder + { + public static DynamoDBEventAttribute Build(AttributeData att) + { + if (att.ConstructorArguments.Length != 1) + { + throw new NotSupportedException($"{TypeFullNames.DynamoDBEventAttribute} must have constructor with 1 argument."); + } + var stream = att.ConstructorArguments[0].Value as string; + var data = new DynamoDBEventAttribute(stream); + + foreach (var pair in att.NamedArguments) + { + if (pair.Key == nameof(data.ResourceName) && pair.Value.Value is string resourceName) + { + data.ResourceName = resourceName; + } + if (pair.Key == nameof(data.BatchSize) && pair.Value.Value is uint batchSize) + { + data.BatchSize = batchSize; + } + else if (pair.Key == nameof(data.StartingPosition) && pair.Value.Value is string startingPosition) + { + data.StartingPosition = startingPosition; + } + else if (pair.Key == nameof(data.Enabled) && pair.Value.Value is bool enabled) + { + data.Enabled = enabled; + } + else if (pair.Key == nameof(data.MaximumBatchingWindowInSeconds) && pair.Value.Value is uint maximumBatchingWindowInSeconds) + { + data.MaximumBatchingWindowInSeconds = maximumBatchingWindowInSeconds; + } + else if (pair.Key == nameof(data.Filters) && pair.Value.Value is string filters) + { + data.Filters = filters; + } + } + + return data; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs index 3dfc51799..e44d370a2 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs @@ -30,6 +30,10 @@ public static HashSet Build(IMethodSymbol lambdaMethodSymbol, { events.Add(EventType.S3); } + else if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.DynamoDBEventAttribute) + { + events.Add(EventType.DynamoDB); + } else if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.HttpApiAuthorizerAttribute || attribute.AttributeClass.ToDisplayString() == TypeFullNames.RestApiAuthorizerAttribute) { diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs index 2091d6c94..c2e421b02 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs @@ -23,7 +23,8 @@ internal class SyntaxReceiver : ISyntaxContextReceiver { "RestApiAttribute", "RestApi" }, { "SQSEventAttribute", "SQSEvent" }, { "ALBApiAttribute", "ALBApi" }, - { "S3EventAttribute", "S3Event" } + { "S3EventAttribute", "S3Event" }, + { "DynamoDBEventAttribute", "DynamoDBEvent" } }; public List LambdaMethods { get; } = new List(); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs index 59fa1d830..495c5077c 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs @@ -56,6 +56,9 @@ public static class TypeFullNames public const string S3Event = "Amazon.Lambda.S3Events.S3Event"; public const string S3EventAttribute = "Amazon.Lambda.Annotations.S3.S3EventAttribute"; + public const string DynamoDBEvent = "Amazon.Lambda.DynamoDBEvents.DynamoDBEvent"; + public const string DynamoDBEventAttribute = "Amazon.Lambda.Annotations.DynamoDB.DynamoDBEventAttribute"; + public const string LambdaSerializerAttribute = "Amazon.Lambda.Core.LambdaSerializerAttribute"; public const string DefaultLambdaSerializer = "Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer"; @@ -84,7 +87,8 @@ public static class TypeFullNames HttpApiAttribute, SQSEventAttribute, ALBApiAttribute, - S3EventAttribute + S3EventAttribute, + DynamoDBEventAttribute }; } } \ No newline at end of file diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs index 7e728660a..777fdde33 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs @@ -4,6 +4,7 @@ using Amazon.Lambda.Annotations.SourceGenerator.Extensions; using Amazon.Lambda.Annotations.SourceGenerator.Models; using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; +using Amazon.Lambda.Annotations.DynamoDB; using Amazon.Lambda.Annotations.SQS; using Microsoft.CodeAnalysis; using System.Collections.Generic; @@ -61,6 +62,7 @@ internal static bool ValidateFunction(GeneratorExecutionContext context, IMethod // Validate Events ValidateApiGatewayEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateSqsEvents(lambdaFunctionModel, methodLocation, diagnostics); + ValidateDynamoDBEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateAlbEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateS3Events(lambdaFunctionModel, methodLocation, diagnostics); @@ -110,6 +112,16 @@ internal static bool ValidateDependencies(GeneratorExecutionContext context, IMe } } + // Check for references to "Amazon.Lambda.DynamoDBEvents" if the Lambda method is annotated with DynamoDBEvent attribute. + if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.DynamoDBEventAttribute)) + { + if (context.Compilation.ReferencedAssemblyNames.FirstOrDefault(x => x.Name == "Amazon.Lambda.DynamoDBEvents") == null) + { + diagnosticReporter.Report(Diagnostic.Create(DiagnosticDescriptors.MissingDependencies, methodLocation, "Amazon.Lambda.DynamoDBEvents")); + return false; + } + } + return true; } @@ -420,6 +432,45 @@ private static void ValidateS3Events(LambdaFunctionModel lambdaFunctionModel, Lo } } + private static void ValidateDynamoDBEvents(LambdaFunctionModel lambdaFunctionModel, Location methodLocation, List diagnostics) + { + if (!lambdaFunctionModel.LambdaMethod.Events.Contains(EventType.DynamoDB)) + { + return; + } + + // Validate DynamoDBEventAttributes + foreach (var att in lambdaFunctionModel.Attributes) + { + if (att.Type.FullName != TypeFullNames.DynamoDBEventAttribute) + continue; + + var dynamoDBEventAttribute = ((AttributeModel)att).Data; + var validationErrors = dynamoDBEventAttribute.Validate(); + validationErrors.ForEach(errorMessage => diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidDynamoDBEventAttribute, methodLocation, errorMessage))); + } + + // Validate method parameters - When using DynamoDBEventAttribute, the method signature must be (DynamoDBEvent evnt) or (DynamoDBEvent evnt, ILambdaContext context) + var parameters = lambdaFunctionModel.LambdaMethod.Parameters; + if (parameters.Count == 0 || + parameters.Count > 2 || + (parameters.Count == 1 && parameters[0].Type.FullName != TypeFullNames.DynamoDBEvent) || + (parameters.Count == 2 && (parameters[0].Type.FullName != TypeFullNames.DynamoDBEvent || parameters[1].Type.FullName != TypeFullNames.ILambdaContext))) + { + var errorMessage = $"When using the {nameof(DynamoDBEventAttribute)}, the Lambda method can accept at most 2 parameters. " + + $"The first parameter is required and must be of type {TypeFullNames.DynamoDBEvent}. " + + $"The second parameter is optional and must be of type {TypeFullNames.ILambdaContext}."; + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidLambdaMethodSignature, methodLocation, errorMessage)); + } + + // Validate method return type - When using DynamoDBEventAttribute, the return type must be either void or Task + if (!lambdaFunctionModel.LambdaMethod.ReturnsVoid && !lambdaFunctionModel.LambdaMethod.ReturnsVoidTask) + { + var errorMessage = $"When using the {nameof(DynamoDBEventAttribute)}, the Lambda method can return either void or {TypeFullNames.Task}"; + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidLambdaMethodSignature, methodLocation, errorMessage)); + } + } + private static bool ReportDiagnostics(DiagnosticReporter diagnosticReporter, List diagnostics) { var isValid = true; diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs index a72e3241b..445324443 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs @@ -1,4 +1,5 @@ using Amazon.Lambda.Annotations.ALB; +using Amazon.Lambda.Annotations.DynamoDB; using Amazon.Lambda.Annotations.APIGateway; using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics; using Amazon.Lambda.Annotations.SourceGenerator.FileIO; @@ -232,6 +233,10 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la eventName = ProcessS3Attribute(lambdaFunction, s3AttributeModel.Data, currentSyncedEventProperties); currentSyncedEvents.Add(eventName); break; + case AttributeModel dynamoDBAttributeModel: + eventName = ProcessDynamoDBAttribute(lambdaFunction, dynamoDBAttributeModel.Data, currentSyncedEventProperties); + currentSyncedEvents.Add(eventName); + break; } } @@ -608,6 +613,68 @@ private string ProcessSqsAttribute(ILambdaFunctionSerializable lambdaFunction, S return att.ResourceName; } + /// + /// Writes all properties associated with to the serverless template. + /// + private string ProcessDynamoDBAttribute(ILambdaFunctionSerializable lambdaFunction, DynamoDBEventAttribute att, Dictionary> syncedEventProperties) + { + var eventName = att.ResourceName; + var eventPath = $"Resources.{lambdaFunction.ResourceName}.Properties.Events.{eventName}"; + + _templateWriter.SetToken($"{eventPath}.Type", "DynamoDB"); + + // Stream + _templateWriter.RemoveToken($"{eventPath}.Properties.Stream"); + if (!att.Stream.StartsWith("@")) + { + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "Stream", att.Stream); + } + else + { + var resource = att.Stream.Substring(1); + if (_templateWriter.Exists($"{PARAMETERS}.{resource}")) + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, $"Stream.{REF}", resource); + else + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, $"Stream.{GET_ATTRIBUTE}", new List { resource, "StreamArn" }, TokenType.List); + } + + // StartingPosition + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "StartingPosition", att.StartingPosition); + + // BatchSize + if (att.IsBatchSizeSet) + { + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "BatchSize", att.BatchSize); + } + + // Enabled + if (att.IsEnabledSet) + { + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "Enabled", att.Enabled); + } + + // MaximumBatchingWindowInSeconds + if (att.IsMaximumBatchingWindowInSecondsSet) + { + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "MaximumBatchingWindowInSeconds", att.MaximumBatchingWindowInSeconds); + } + + // FilterCriteria + if (att.IsFiltersSet) + { + const char SEPERATOR = ';'; + var filters = att.Filters.Split(SEPERATOR).Select(x => x.Trim()).ToList(); + var filterList = new List>(); + foreach (var filter in filters) + { + filterList.Add(new Dictionary { { "Pattern", filter } }); + } + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "FilterCriteria.Filters", filterList, TokenType.List); + } + + return att.ResourceName; + } + /// /// Writes all properties associated with to the serverless template. /// diff --git a/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs new file mode 100644 index 000000000..d85c0963c --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Amazon.Lambda.Annotations.DynamoDB +{ + /// + /// This attribute defines the DynamoDB event source configuration for a Lambda function. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + public class DynamoDBEventAttribute : Attribute + { + private static readonly Regex _resourceNameRegex = new Regex("^[a-zA-Z0-9]+$"); + + /// + /// The DynamoDB stream that will act as the event trigger for the Lambda function. + /// This can either be the stream ARN or reference to the DynamoDB table resource that is already defined in the serverless template. + /// To reference a DynamoDB table resource in the serverless template, prefix the resource name with "@" symbol. + /// + public string Stream { get; set; } + + /// + /// The CloudFormation resource name for the DynamoDB event source mapping. + /// + public string ResourceName + { + get + { + if (IsResourceNameSet) + { + return resourceName; + } + if (Stream.StartsWith("@")) + { + return Stream.Substring(1); + } + + // DynamoDB stream ARN format: arn:aws:dynamodb:region:account:table/TableName/stream/timestamp + var arnParts = Stream.Split('/'); + if (arnParts.Length >= 2) + { + var tableName = arnParts[1]; + return string.Join(string.Empty, tableName.Where(char.IsLetterOrDigit)); + } + return string.Join(string.Empty, Stream.Where(char.IsLetterOrDigit)); + } + set => resourceName = value; + } + + private string resourceName { get; set; } = null; + internal bool IsResourceNameSet => resourceName != null; + + /// + /// The maximum number of records in each batch that Lambda pulls from the stream. + /// Default value is 100. + /// + public uint BatchSize + { + get => batchSize.GetValueOrDefault(); + set => batchSize = value; + } + private uint? batchSize { get; set; } + internal bool IsBatchSizeSet => batchSize.HasValue; + + /// + /// The position in the stream where Lambda starts reading. Valid values are TRIM_HORIZON and LATEST. + /// Default value is LATEST. + /// + public string StartingPosition { get; set; } = "LATEST"; + internal bool IsStartingPositionSet => true; + + /// + /// If set to false, the event source mapping will be disabled. Default value is true. + /// + public bool Enabled + { + get => enabled.GetValueOrDefault(); + set => enabled = value; + } + private bool? enabled { get; set; } + internal bool IsEnabledSet => enabled.HasValue; + + /// + /// The maximum amount of time, in seconds, to gather records before invoking the function. + /// + public uint MaximumBatchingWindowInSeconds + { + get => maximumBatchingWindowInSeconds.GetValueOrDefault(); + set => maximumBatchingWindowInSeconds = value; + } + private uint? maximumBatchingWindowInSeconds { get; set; } + internal bool IsMaximumBatchingWindowInSecondsSet => maximumBatchingWindowInSeconds.HasValue; + + /// + /// A collection of semicolon (;) separated strings where each string denotes a filter pattern. + /// + public string Filters { get; set; } = null; + internal bool IsFiltersSet => Filters != null; + + /// + /// Creates an instance of the class. + /// + /// property + public DynamoDBEventAttribute(string stream) + { + Stream = stream; + } + + internal List Validate() + { + var validationErrors = new List(); + + if (IsBatchSizeSet && (BatchSize < 1 || BatchSize > 10000)) + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.BatchSize)} = {BatchSize}. It must be between 1 and 10000"); + } + if (IsMaximumBatchingWindowInSecondsSet && MaximumBatchingWindowInSeconds > 300) + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.MaximumBatchingWindowInSeconds)} = {MaximumBatchingWindowInSeconds}. It must be between 0 and 300"); + } + if (!string.IsNullOrEmpty(StartingPosition) && StartingPosition != "TRIM_HORIZON" && StartingPosition != "LATEST") + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.StartingPosition)} = {StartingPosition}. It must be either TRIM_HORIZON or LATEST"); + } + if (!Stream.StartsWith("@")) + { + if (!Stream.Contains(":dynamodb:") && !Stream.Contains("/stream/")) + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.Stream)} = {Stream}. The DynamoDB stream ARN is invalid"); + } + } + if (IsResourceNameSet && !_resourceNameRegex.IsMatch(ResourceName)) + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.ResourceName)} = {ResourceName}. It must only contain alphanumeric characters and must not be an empty string"); + } + + return validationErrors; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations/README.md b/Libraries/src/Amazon.Lambda.Annotations/README.md index d736e06be..cba9b8a76 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/README.md +++ b/Libraries/src/Amazon.Lambda.Annotations/README.md @@ -19,6 +19,7 @@ Topics: - [Amazon API Gateway example](#amazon-api-gateway-example) - [Amazon S3 example](#amazon-s3-example) - [SQS Event Example](#sqs-event-example) + - [DynamoDB Event Example](#dynamodb-event-example) - [Application Load Balancer (ALB) Example](#application-load-balancer-alb-example) - [Custom Lambda Authorizer Example](#custom-lambda-authorizer-example) - [HTTP API Authorizer](#http-api-authorizer) @@ -1073,6 +1074,27 @@ The `ALBApi` attribute requires an existing ALB listener. Here is a minimal exam Then your Lambda function references `@MyListener` in the `ALBApi` attribute. +## DynamoDB Event Example +This example shows how to use the `DynamoDBEvent` attribute to set up event source mapping between a DynamoDB stream and a Lambda function. + +The `DynamoDBEvent` attribute contains the following properties: +* **Stream** (Required) - The DynamoDB stream ARN or a reference to a DynamoDB table resource prefixed with "@". When using a resource reference, the source generator uses `Fn::GetAtt` to get the `StreamArn`. +* **ResourceName** (Optional) - The CloudFormation resource name for the event source mapping. +* **StartingPosition** (Optional) - The position in the stream to start reading. Valid values: `TRIM_HORIZON`, `LATEST`. Default is `LATEST`. +* **BatchSize** (Optional) - The maximum number of records per batch. Must be between 1 and 10000. Default is 100. +* **MaximumBatchingWindowInSeconds** (Optional) - Maximum time to gather records before invoking. Must be between 0 and 300. +* **Enabled** (Optional) - If false, the event source mapping is disabled. Default is true. +* **Filters** (Optional) - Semicolon-separated filter patterns for event filtering. + +```csharp +[LambdaFunction(ResourceName = "DynamoDBStreamHandler", Policies = "AWSLambdaDynamoDBExecutionRole", PackageType = LambdaPackageType.Image)] +[DynamoDBEvent("@TestTable", ResourceName = "TestTableStream", BatchSize = 100, StartingPosition = "TRIM_HORIZON")] +public void HandleStream(DynamoDBEvent evnt, ILambdaContext lambdaContext) +{ + lambdaContext.Logger.Log($"Received {evnt.Records.Count} records"); +} +``` + ## Custom Lambda Authorizer Example Lambda Annotations supports defining custom Lambda authorizers using attributes. Custom authorizers let you control access to your API Gateway endpoints by running a Lambda function that validates tokens or request parameters before the target function is invoked. The source generator automatically wires up the authorizer resources and references in the CloudFormation template. @@ -1420,6 +1442,8 @@ parameter to the `LambdaFunction` must be the event object and the event source * Marks a Lambda function as a REST API (API Gateway V1) custom authorizer. The authorizer name is automatically derived from the method name. Other functions reference it via `RestApi.Authorizer` using `nameof()`. Use the `Type` property to choose between `Token` and `Request` authorizer types. * SQSEvent * Sets up event source mapping between the Lambda function and SQS queues. The SQS queue ARN is required to be set on the attribute. If users want to pass a reference to an existing SQS queue resource defined in their CloudFormation template, they can pass the SQS queue resource name prefixed with the '@' symbol. +* DynamoDBEvent + * Sets up event source mapping between the Lambda function and DynamoDB streams. The stream ARN or table resource reference (prefixed with '@') is required. * ALBApi * Configures the Lambda function to be called from an Application Load Balancer. The listener ARN (or `@ResourceName` template reference), path pattern, and priority are required. The source generator creates standalone CloudFormation resources (TargetGroup, ListenerRule, Lambda Permission) rather than SAM event types. diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj index 56da7d597..856ada2cf 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj @@ -208,6 +208,7 @@ + + diff --git a/Libraries/test/TestServerlessApp/DynamoDBEventExamples/ValidDynamoDBEvents.cs.txt b/Libraries/test/TestServerlessApp/DynamoDBEventExamples/ValidDynamoDBEvents.cs.txt new file mode 100644 index 000000000..a6fd76cda --- /dev/null +++ b/Libraries/test/TestServerlessApp/DynamoDBEventExamples/ValidDynamoDBEvents.cs.txt @@ -0,0 +1,30 @@ +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.DynamoDB; +using Amazon.Lambda.DynamoDBEvents; +using System; +using System.Threading.Tasks; + +namespace TestServerlessApp.DynamoDBEventExamples +{ + // This file represents valid usage of the DynamoDBEventAttribute. This is added as .txt file since we do not want to deploy these functions during our integration tests. + // This file is only sent as input to the source generator unit tests. + + public class ValidDynamoDBEvents + { + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [DynamoDBEvent("arn:aws:dynamodb:us-east-2:444455556666:table/MyTable/stream/2024-01-01T00:00:00", BatchSize = 50, MaximumBatchingWindowInSeconds = 2, Filters = "My-Filter-1; My-Filter-2")] + [DynamoDBEvent("arn:aws:dynamodb:us-east-2:444455556666:table/MyTable2/stream/2024-01-01T00:00:00", StartingPosition = "TRIM_HORIZON", Enabled = false)] + [DynamoDBEvent("@testTable", ResourceName = "testTableEvent")] + public void ProcessMessages(DynamoDBEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [DynamoDBEvent("arn:aws:dynamodb:us-east-2:444455556666:table/MyTable/stream/2024-01-01T00:00:00")] + public async Task ProcessMessagesAsync(DynamoDBEvent evnt) + { + await Console.Out.WriteLineAsync($"Event processed: {evnt}"); + } + } +} diff --git a/Libraries/test/TestServerlessApp/DynamoDbStreamProcessing.cs b/Libraries/test/TestServerlessApp/DynamoDbStreamProcessing.cs new file mode 100644 index 000000000..29eacb3a2 --- /dev/null +++ b/Libraries/test/TestServerlessApp/DynamoDbStreamProcessing.cs @@ -0,0 +1,17 @@ +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.DynamoDB; +using Amazon.Lambda.Core; +using Amazon.Lambda.DynamoDBEvents; + +namespace TestServerlessApp +{ + public class DynamoDbStreamProcessing + { + [LambdaFunction(ResourceName = "DynamoDBStreamHandler", Policies = "AWSLambdaDynamoDBExecutionRole", PackageType = LambdaPackageType.Image)] + [DynamoDBEvent("@TestTable", ResourceName = "TestTableStream", BatchSize = 100, StartingPosition = "TRIM_HORIZON")] + public void HandleStream(DynamoDBEvent evnt, ILambdaContext lambdaContext) + { + lambdaContext.Logger.Log($"Received {evnt.Records.Count} records"); + } + } +} diff --git a/Libraries/test/TestServerlessApp/TestServerlessApp.csproj b/Libraries/test/TestServerlessApp/TestServerlessApp.csproj index 83d7cf89e..9e1f4993c 100644 --- a/Libraries/test/TestServerlessApp/TestServerlessApp.csproj +++ b/Libraries/test/TestServerlessApp/TestServerlessApp.csproj @@ -27,6 +27,7 @@ + diff --git a/Libraries/test/TestServerlessApp/serverless.template b/Libraries/test/TestServerlessApp/serverless.template index d5b4b3cb0..0b0606fbd 100644 --- a/Libraries/test/TestServerlessApp/serverless.template +++ b/Libraries/test/TestServerlessApp/serverless.template @@ -6,6 +6,27 @@ "TestQueue": { "Type": "AWS::SQS::Queue" }, + "TestTable": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "AttributeDefinitions": [ + { + "AttributeName": "Id", + "AttributeType": "S" + } + ], + "KeySchema": [ + { + "AttributeName": "Id", + "KeyType": "HASH" + } + ], + "BillingMode": "PAY_PER_REQUEST", + "StreamSpecification": { + "StreamViewType": "NEW_AND_OLD_IMAGES" + } + } + }, "AnnotationsHttpApi": { "Type": "AWS::Serverless::HttpApi", "Metadata": {