From 5260199b0f6b115a557fd46e903c812354ab36ff Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Mon, 13 Apr 2026 17:24:25 +0000 Subject: [PATCH 1/8] 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 pr comments --- .../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 | 1 + .../TypeFullNames.cs | 4 + .../Validation/LambdaFunctionValidator.cs | 51 +++ .../Writers/CloudFormationWriter.cs | 67 ++++ .../DynamoDB/DynamoDBEventAttribute.cs | 155 +++++++++ ....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 | 14 +- .../TestServerlessApp.IntegrationTests.csproj | 1 + .../ValidDynamoDBEvents.cs.txt | 30 ++ .../DynamoDbStreamProcessing.cs | 17 + .../TestServerlessApp.csproj | 1 + 25 files changed, 1294 insertions(+), 1 deletion(-) 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 dc97dd7de..4c02947e0 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md @@ -21,4 +21,5 @@ 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 AWSLambda0138 | AWSLambdaCSharpGenerator | Error | Invalid SNSEventAttribute diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs index 569fd4116..fbbdeadc3 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs @@ -282,6 +282,13 @@ public static class DiagnosticDescriptors 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); + public static readonly DiagnosticDescriptor InvalidSnsEventAttribute = new DiagnosticDescriptor(id: "AWSLambda0138", title: "Invalid SNSEventAttribute", messageFormat: "Invalid SNSEventAttribute encountered: {0}", 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 add735fe5..bd03c4765 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs @@ -4,6 +4,7 @@ using System; using Amazon.Lambda.Annotations.ALB; using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.Annotations.DynamoDB; using Amazon.Lambda.Annotations.S3; using Amazon.Lambda.Annotations.SQS; using Microsoft.CodeAnalysis; @@ -113,6 +114,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.SNSEventAttribute), SymbolEqualityComparer.Default)) { var data = SNSEventAttributeBuilder.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 960b83d97..d5249546b 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs @@ -34,6 +34,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.SNSEventAttribute) { events.Add(EventType.SNS); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs index e54d97b44..cafee8081 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs @@ -28,6 +28,7 @@ internal class SyntaxReceiver : ISyntaxContextReceiver { "SQSEventAttribute", "SQSEvent" }, { "ALBApiAttribute", "ALBApi" }, { "S3EventAttribute", "S3Event" }, + { "DynamoDBEventAttribute", "DynamoDBEvent" }, { "SNSEventAttribute", "SNSEvent" } }; diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs index 40059193a..9ea767031 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs @@ -62,6 +62,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 SNSEvent = "Amazon.Lambda.SNSEvents.SNSEvent"; public const string SNSEventAttribute = "Amazon.Lambda.Annotations.SNS.SNSEventAttribute"; @@ -95,6 +98,7 @@ public static class TypeFullNames SQSEventAttribute, ALBApiAttribute, S3EventAttribute, + DynamoDBEventAttribute, SNSEventAttribute }; } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs index 7193779ed..c0042dab1 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs @@ -7,6 +7,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.SNS; using Amazon.Lambda.Annotations.SQS; using Microsoft.CodeAnalysis; @@ -65,6 +66,7 @@ internal static bool ValidateFunction(GeneratorExecutionContext context, IMethod // Validate Events ValidateApiGatewayEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateSqsEvents(lambdaFunctionModel, methodLocation, diagnostics); + ValidateDynamoDBEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateSnsEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateAlbEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateS3Events(lambdaFunctionModel, methodLocation, diagnostics); @@ -116,6 +118,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; + } + } + // Check for references to "Amazon.Lambda.SNSEvents" if the Lambda method is annotated with SNSEvent attribute. if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.SNSEventAttribute)) { @@ -436,6 +448,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 void ValidateSnsEvents(LambdaFunctionModel lambdaFunctionModel, Location methodLocation, List diagnostics) { if (!lambdaFunctionModel.LambdaMethod.Events.Contains(EventType.SNS)) diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs index f34f2d8bb..92bf23053 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 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; @@ -242,6 +243,10 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la _templateWriter.SetToken($"Resources.{lambdaFunction.ResourceName}.Metadata.SyncedFunctionUrlConfig", true); hasFunctionUrl = true; break; + case AttributeModel dynamoDBAttributeModel: + eventName = ProcessDynamoDBAttribute(lambdaFunction, dynamoDBAttributeModel.Data, currentSyncedEventProperties); + currentSyncedEvents.Add(eventName); + break; case AttributeModel snsAttributeModel: eventName = ProcessSnsAttribute(lambdaFunction, snsAttributeModel.Data, currentSyncedEventProperties); currentSyncedEvents.Add(eventName); @@ -678,6 +683,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..3f078ce21 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs @@ -0,0 +1,155 @@ +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 (string.IsNullOrWhiteSpace(Stream)) + { + return string.Empty; + } + + if (Stream.StartsWith("@")) + { + return Stream.Length > 1 ? Stream.Substring(1) : string.Empty; + } + + // 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(100); + 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(true); + 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)) + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.StartingPosition)} must not be null or empty. It must be either TRIM_HORIZON or LATEST"); + } + else if (StartingPosition != "TRIM_HORIZON" && StartingPosition != "LATEST") + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.StartingPosition)} = {StartingPosition}. It must be either TRIM_HORIZON or LATEST"); + } + if (string.IsNullOrWhiteSpace(Stream)) + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.Stream)} must not be null or empty"); + } + else 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/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 a68fced1f..e99dce51a 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/Amazon.Lambda.Annotations.SourceGenerators.Tests/CSharpSourceGeneratorVerifier.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/CSharpSourceGeneratorVerifier.cs index 026357530..75417bee1 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/CSharpSourceGeneratorVerifier.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/CSharpSourceGeneratorVerifier.cs @@ -3,6 +3,7 @@ using Amazon.Lambda.Core; using Amazon.Lambda.RuntimeSupport; using Amazon.Lambda.Serialization.SystemTextJson; +using Amazon.Lambda.DynamoDBEvents; using Amazon.Lambda.SNSEvents; using Amazon.Lambda.ApplicationLoadBalancerEvents; using Amazon.Lambda.SQSEvents; @@ -59,6 +60,7 @@ public Test(ReferencesMode referencesMode = ReferencesMode.All, TargetFramework { return solution.AddMetadataReference(projectId, MetadataReference.CreateFromFile(typeof(ILambdaContext).Assembly.Location)) .AddMetadataReference(projectId, MetadataReference.CreateFromFile(typeof(APIGatewayProxyRequest).Assembly.Location)) + .AddMetadataReference(projectId, MetadataReference.CreateFromFile(typeof(DynamoDBEvent).Assembly.Location)) .AddMetadataReference(projectId, MetadataReference.CreateFromFile(typeof(SNSEvent).Assembly.Location)) .AddMetadataReference(projectId, MetadataReference.CreateFromFile(typeof(SQSEvent).Assembly.Location)) .AddMetadataReference(projectId, MetadataReference.CreateFromFile(typeof(ApplicationLoadBalancerRequest).Assembly.Location)) diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DynamoDBEventAttributeTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DynamoDBEventAttributeTests.cs new file mode 100644 index 000000000..72663632c --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DynamoDBEventAttributeTests.cs @@ -0,0 +1,304 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.DynamoDB; +using Xunit; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests +{ + public class DynamoDBEventAttributeTests + { + // ===== Constructor and Default Values ===== + + [Fact] + public void Constructor_SetsStreamProperty() + { + var attr = new DynamoDBEventAttribute("@MyTable"); + + Assert.Equal("@MyTable", attr.Stream); + } + + [Fact] + public void DefaultValues_AreCorrect() + { + var attr = new DynamoDBEventAttribute("@MyTable"); + + Assert.Equal("LATEST", attr.StartingPosition); + Assert.False(attr.IsBatchSizeSet); + Assert.False(attr.IsEnabledSet); + Assert.False(attr.IsMaximumBatchingWindowInSecondsSet); + Assert.Null(attr.Filters); + Assert.False(attr.IsFiltersSet); + Assert.False(attr.IsResourceNameSet); + Assert.Equal("MyTable", attr.ResourceName); + } + + // ===== ResourceName Tests ===== + + [Fact] + public void ResourceName_DerivedFromStream_WithAtPrefix() + { + var attr = new DynamoDBEventAttribute("@TestTable"); + + Assert.False(attr.IsResourceNameSet); + Assert.Equal("TestTable", attr.ResourceName); + } + + [Fact] + public void ResourceName_DerivedFromStreamArn() + { + var attr = new DynamoDBEventAttribute("arn:aws:dynamodb:us-east-1:123456789:table/MyTable/stream/2024-01-01T00:00:00.000"); + + Assert.False(attr.IsResourceNameSet); + Assert.Equal("MyTable", attr.ResourceName); + } + + [Fact] + public void ResourceName_WhenExplicitlySet_IsTracked() + { + var attr = new DynamoDBEventAttribute("@MyTable"); + + Assert.False(attr.IsResourceNameSet); + + attr.ResourceName = "CustomEventName"; + Assert.True(attr.IsResourceNameSet); + Assert.Equal("CustomEventName", attr.ResourceName); + } + + // ===== BatchSize Tests ===== + + [Fact] + public void BatchSize_DefaultNotSet() + { + var attr = new DynamoDBEventAttribute("@MyTable"); + + Assert.False(attr.IsBatchSizeSet); + } + + [Fact] + public void BatchSize_WhenSet_IsTracked() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + BatchSize = 50 + }; + + Assert.True(attr.IsBatchSizeSet); + Assert.Equal((uint)50, attr.BatchSize); + } + + // ===== StartingPosition Tests ===== + + [Fact] + public void StartingPosition_DefaultValue_IsLatest() + { + var attr = new DynamoDBEventAttribute("@MyTable"); + + Assert.Equal("LATEST", attr.StartingPosition); + } + + [Fact] + public void StartingPosition_CanBeSetToTrimHorizon() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + StartingPosition = "TRIM_HORIZON" + }; + + Assert.Equal("TRIM_HORIZON", attr.StartingPosition); + } + + // ===== MaximumBatchingWindowInSeconds Tests ===== + + [Fact] + public void MaximumBatchingWindowInSeconds_DefaultNotSet() + { + var attr = new DynamoDBEventAttribute("@MyTable"); + + Assert.False(attr.IsMaximumBatchingWindowInSecondsSet); + } + + [Fact] + public void MaximumBatchingWindowInSeconds_WhenSet_IsTracked() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + MaximumBatchingWindowInSeconds = 60 + }; + + Assert.True(attr.IsMaximumBatchingWindowInSecondsSet); + Assert.Equal((uint)60, attr.MaximumBatchingWindowInSeconds); + } + + // ===== Enabled Property Tests ===== + + [Fact] + public void Enabled_DefaultNotSet() + { + var attr = new DynamoDBEventAttribute("@MyTable"); + + Assert.False(attr.IsEnabledSet); + } + + [Fact] + public void Enabled_WhenSetToFalse_IsTracked() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + Enabled = false + }; + + Assert.True(attr.IsEnabledSet); + Assert.False(attr.Enabled); + } + + [Fact] + public void Enabled_WhenSetToTrue_IsTracked() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + Enabled = true + }; + + Assert.True(attr.IsEnabledSet); + Assert.True(attr.Enabled); + } + + // ===== Filters Tests ===== + + [Fact] + public void Filters_DefaultIsNull() + { + var attr = new DynamoDBEventAttribute("@MyTable"); + + Assert.Null(attr.Filters); + Assert.False(attr.IsFiltersSet); + } + + [Fact] + public void Filters_WhenSet_IsTracked() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + Filters = "{\"eventName\": [\"INSERT\"]}" + }; + + Assert.True(attr.IsFiltersSet); + Assert.Equal("{\"eventName\": [\"INSERT\"]}", attr.Filters); + } + + // ===== Validation Tests ===== + + [Fact] + public void Validate_ValidResourceReference_ReturnsNoErrors() + { + var attr = new DynamoDBEventAttribute("@MyTable"); + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_ValidStreamArn_ReturnsNoErrors() + { + var attr = new DynamoDBEventAttribute("arn:aws:dynamodb:us-east-1:123456789:table/MyTable/stream/2024-01-01T00:00:00.000"); + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_InvalidStreamArn_ReturnsError() + { + var attr = new DynamoDBEventAttribute("not-a-valid-arn"); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("Stream", errors[0]); + } + + [Fact] + public void Validate_InvalidResourceName_ReturnsError() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + ResourceName = "invalid-name!" + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("ResourceName", errors[0]); + Assert.Contains("alphanumeric", errors[0]); + } + + [Fact] + public void Validate_BatchSizeTooLarge_ReturnsError() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + BatchSize = 10001 + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("BatchSize", errors[0]); + } + + [Fact] + public void Validate_MaxBatchingWindowTooLarge_ReturnsError() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + MaximumBatchingWindowInSeconds = 301 + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("MaximumBatchingWindowInSeconds", errors[0]); + } + + [Fact] + public void Validate_InvalidStartingPosition_ReturnsError() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + StartingPosition = "INVALID" + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("StartingPosition", errors[0]); + } + + [Fact] + public void Validate_MultipleErrors_ReturnsAll() + { + var attr = new DynamoDBEventAttribute("not-valid") + { + ResourceName = "invalid!", + BatchSize = 10001, + StartingPosition = "INVALID" + }; + + var errors = attr.Validate(); + Assert.Equal(4, errors.Count); + } + + [Fact] + public void Validate_AllValidWithOptionals_ReturnsNoErrors() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + ResourceName = "MyDynamoDBEvent", + BatchSize = 100, + StartingPosition = "TRIM_HORIZON", + MaximumBatchingWindowInSeconds = 60, + Filters = "{\"eventName\": [\"INSERT\"]}", + Enabled = true + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/DynamoDB/ValidDynamoDBEvents_ProcessMessagesAsync_Generated.g.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/DynamoDB/ValidDynamoDBEvents_ProcessMessagesAsync_Generated.g.cs new file mode 100644 index 000000000..986b51396 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/DynamoDB/ValidDynamoDBEvents_ProcessMessagesAsync_Generated.g.cs @@ -0,0 +1,57 @@ +// + +using System; +using System.Linq; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using System.IO; +using Amazon.Lambda.Core; + +namespace TestServerlessApp.DynamoDBEventExamples +{ + public class ValidDynamoDBEvents_ProcessMessagesAsync_Generated + { + private readonly ValidDynamoDBEvents validDynamoDBEvents; + private readonly Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer serializer; + + /// + /// Default constructor. This constructor is used by Lambda to construct the instance. When invoked in a Lambda environment + /// the AWS credentials will come from the IAM role associated with the function and the AWS region will be set to the + /// region the Lambda function is executed in. + /// + public ValidDynamoDBEvents_ProcessMessagesAsync_Generated() + { + SetExecutionEnvironment(); + validDynamoDBEvents = new ValidDynamoDBEvents(); + serializer = new Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer(); + } + + /// + /// The generated Lambda function handler for + /// + /// The request object that will be processed by the Lambda function handler. + /// Result of the Lambda function execution + public async System.Threading.Tasks.Task ProcessMessagesAsync(Amazon.Lambda.DynamoDBEvents.DynamoDBEvent __evnt__) + { + await validDynamoDBEvents.ProcessMessagesAsync(__evnt__); + } + + private static void SetExecutionEnvironment() + { + const string envName = "AWS_EXECUTION_ENV"; + + var envValue = new StringBuilder(); + + // If there is an existing execution environment variable add the annotations package as a suffix. + if(!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(envName))) + { + envValue.Append($"{Environment.GetEnvironmentVariable(envName)}_"); + } + + envValue.Append("lib/amazon-lambda-annotations#{ANNOTATIONS_ASSEMBLY_VERSION}"); + + Environment.SetEnvironmentVariable(envName, envValue.ToString()); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/DynamoDB/ValidDynamoDBEvents_ProcessMessages_Generated.g.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/DynamoDB/ValidDynamoDBEvents_ProcessMessages_Generated.g.cs new file mode 100644 index 000000000..814090041 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/DynamoDB/ValidDynamoDBEvents_ProcessMessages_Generated.g.cs @@ -0,0 +1,57 @@ +// + +using System; +using System.Linq; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using System.IO; +using Amazon.Lambda.Core; + +namespace TestServerlessApp.DynamoDBEventExamples +{ + public class ValidDynamoDBEvents_ProcessMessages_Generated + { + private readonly ValidDynamoDBEvents validDynamoDBEvents; + private readonly Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer serializer; + + /// + /// Default constructor. This constructor is used by Lambda to construct the instance. When invoked in a Lambda environment + /// the AWS credentials will come from the IAM role associated with the function and the AWS region will be set to the + /// region the Lambda function is executed in. + /// + public ValidDynamoDBEvents_ProcessMessages_Generated() + { + SetExecutionEnvironment(); + validDynamoDBEvents = new ValidDynamoDBEvents(); + serializer = new Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer(); + } + + /// + /// The generated Lambda function handler for + /// + /// The request object that will be processed by the Lambda function handler. + /// Result of the Lambda function execution + public void ProcessMessages(Amazon.Lambda.DynamoDBEvents.DynamoDBEvent __evnt__) + { + validDynamoDBEvents.ProcessMessages(__evnt__); + } + + private static void SetExecutionEnvironment() + { + const string envName = "AWS_EXECUTION_ENV"; + + var envValue = new StringBuilder(); + + // If there is an existing execution environment variable add the annotations package as a suffix. + if(!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(envName))) + { + envValue.Append($"{Environment.GetEnvironmentVariable(envName)}_"); + } + + envValue.Append("lib/amazon-lambda-annotations#{ANNOTATIONS_ASSEMBLY_VERSION}"); + + Environment.SetEnvironmentVariable(envName, envValue.ToString()); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/dynamoDBEvents.template b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/dynamoDBEvents.template new file mode 100644 index 000000000..5fbcf8d48 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/dynamoDBEvents.template @@ -0,0 +1,129 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::Serverless-2016-10-31", + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v{ANNOTATIONS_ASSEMBLY_VERSION}).", + "Resources": { + "TestServerlessAppDynamoDBEventExamplesValidDynamoDBEventsProcessMessagesGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "MyTable", + "MyTable2", + "testTableEvent" + ], + "SyncedEventProperties": { + "MyTable": [ + "Stream", + "StartingPosition", + "BatchSize", + "MaximumBatchingWindowInSeconds", + "FilterCriteria.Filters" + ], + "MyTable2": [ + "Stream", + "StartingPosition", + "Enabled" + ], + "testTableEvent": [ + "Stream.Fn::GetAtt", + "StartingPosition" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestProject::TestServerlessApp.DynamoDBEventExamples.ValidDynamoDBEvents_ProcessMessages_Generated::ProcessMessages" + ] + }, + "Events": { + "MyTable": { + "Type": "DynamoDB", + "Properties": { + "Stream": "arn:aws:dynamodb:us-east-2:444455556666:table/MyTable/stream/2024-01-01T00:00:00", + "StartingPosition": "LATEST", + "BatchSize": 50, + "MaximumBatchingWindowInSeconds": 2, + "FilterCriteria": { + "Filters": [ + { + "Pattern": "My-Filter-1" + }, + { + "Pattern": "My-Filter-2" + } + ] + } + } + }, + "MyTable2": { + "Type": "DynamoDB", + "Properties": { + "Stream": "arn:aws:dynamodb:us-east-2:444455556666:table/MyTable2/stream/2024-01-01T00:00:00", + "StartingPosition": "TRIM_HORIZON", + "Enabled": false + } + }, + "testTableEvent": { + "Type": "DynamoDB", + "Properties": { + "Stream": { + "Fn::GetAtt": [ + "testTable", + "StreamArn" + ] + }, + "StartingPosition": "LATEST" + } + } + } + } + }, + "TestServerlessAppDynamoDBEventExamplesValidDynamoDBEventsProcessMessagesAsyncGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "MyTable" + ], + "SyncedEventProperties": { + "MyTable": [ + "Stream", + "StartingPosition" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestProject::TestServerlessApp.DynamoDBEventExamples.ValidDynamoDBEvents_ProcessMessagesAsync_Generated::ProcessMessagesAsync" + ] + }, + "Events": { + "MyTable": { + "Type": "DynamoDB", + "Properties": { + "Stream": "arn:aws:dynamodb:us-east-2:444455556666:table/MyTable/stream/2024-01-01T00:00:00", + "StartingPosition": "LATEST" + } + } + } + } + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs index 5824ed3de..f6d5d1c94 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs @@ -1391,6 +1391,53 @@ public async Task VerifyValidALBEvents() }.RunAsync(); } + [Fact] + public async Task VerifyValidDynamoDBEvents() + { + var expectedTemplateContent = await ReadSnapshotContent(Path.Combine("Snapshots", "ServerlessTemplates", "dynamoDBEvents.template")); + var validDynamoDBEventsProcessMessagesGeneratedContent = await ReadSnapshotContent(Path.Combine("Snapshots", "DynamoDB", "ValidDynamoDBEvents_ProcessMessages_Generated.g.cs")); + var validDynamoDBEventsProcessMessagesAsyncGeneratedContent = await ReadSnapshotContent(Path.Combine("Snapshots", "DynamoDB", "ValidDynamoDBEvents_ProcessMessagesAsync_Generated.g.cs")); + + await new VerifyCS.Test + { + TestState = + { + Sources = + { + (Path.Combine("TestServerlessApp", "PlaceholderClass.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "PlaceholderClass.cs"))), + (Path.Combine("TestServerlessApp", "DynamoDBEventExamples", "ValidDynamoDBEvents.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "DynamoDBEventExamples", "ValidDynamoDBEvents.cs.txt"))), + (Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"))), + (Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "DynamoDBEventAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "DynamoDBEventAttribute.cs"))), + (Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"))), + }, + GeneratedSources = + { + ( + typeof(SourceGenerator.Generator), + "ValidDynamoDBEvents_ProcessMessages_Generated.g.cs", + SourceText.From(validDynamoDBEventsProcessMessagesGeneratedContent, Encoding.UTF8, SourceHashAlgorithm.Sha256) + ), + ( + typeof(SourceGenerator.Generator), + "ValidDynamoDBEvents_ProcessMessagesAsync_Generated.g.cs", + SourceText.From(validDynamoDBEventsProcessMessagesAsyncGeneratedContent, Encoding.UTF8, SourceHashAlgorithm.Sha256) + ) + }, + ExpectedDiagnostics = + { + new DiagnosticResult("AWSLambda0103", DiagnosticSeverity.Info) + .WithArguments("ValidDynamoDBEvents_ProcessMessages_Generated.g.cs", validDynamoDBEventsProcessMessagesGeneratedContent), + + new DiagnosticResult("AWSLambda0103", DiagnosticSeverity.Info) + .WithArguments("ValidDynamoDBEvents_ProcessMessagesAsync_Generated.g.cs", validDynamoDBEventsProcessMessagesAsyncGeneratedContent), + + new DiagnosticResult("AWSLambda0103", DiagnosticSeverity.Info) + .WithArguments($"TestServerlessApp{Path.DirectorySeparatorChar}serverless.template", expectedTemplateContent) + } + } + }.RunAsync(); + } + [Fact] public async Task VerifyValidSNSEvents() { diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/DynamoDBEventsTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/DynamoDBEventsTests.cs new file mode 100644 index 000000000..1696edd28 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/DynamoDBEventsTests.cs @@ -0,0 +1,234 @@ +using Amazon.Lambda.Annotations.SourceGenerator; +using Amazon.Lambda.Annotations.SourceGenerator.Models; +using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; +using Amazon.Lambda.Annotations.SourceGenerator.Writers; +using Amazon.Lambda.Annotations.DynamoDB; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests.WriterTests +{ + public partial class CloudFormationWriterTests + { + const string streamArn1 = "arn:aws:dynamodb:us-east-2:444455556666:table/MyTable/stream/2024-01-01T00:00:00"; + const string streamArn2 = "arn:aws:dynamodb:us-east-2:444455556666:table/MyTable2/stream/2024-01-01T00:00:00"; + + [Theory] + [ClassData(typeof(DynamoDBEventsTestData))] + public void VerifyDynamoDBEventAttributes_AreCorrectlyApplied(CloudFormationTemplateFormat templateFormat, IEnumerable dynamoDBEventAttributes) + { + // ARRANGE + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel(); + lambdaFunctionModel.PackageType = LambdaPackageType.Zip; + lambdaFunctionModel.ReturnTypeFullName = "void"; + foreach (var att in dynamoDBEventAttributes) + { + lambdaFunctionModel.Attributes.Add(new AttributeModel { Data = att }); + } + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport([lambdaFunctionModel]); + + // ACT + cloudFormationWriter.ApplyReport(report); + + // ASSERT + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + + foreach (var att in dynamoDBEventAttributes) + { + var eventName = att.ResourceName; + var eventPath = $"Resources.{lambdaFunctionModel.ResourceName}.Properties.Events.{eventName}"; + var eventPropertiesPath = $"{eventPath}.Properties"; + + Assert.True(templateWriter.Exists(eventPath)); + Assert.Equal("DynamoDB", templateWriter.GetToken($"{eventPath}.Type")); + + if (!att.Stream.StartsWith("@")) + { + Assert.Equal(att.Stream, templateWriter.GetToken($"{eventPropertiesPath}.Stream")); + } + else + { + Assert.Equal([att.Stream.Substring(1), "StreamArn"], templateWriter.GetToken>($"{eventPropertiesPath}.Stream.Fn::GetAtt")); + } + + Assert.Equal(att.StartingPosition, templateWriter.GetToken($"{eventPropertiesPath}.StartingPosition")); + + Assert.Equal(att.IsBatchSizeSet, templateWriter.Exists($"{eventPropertiesPath}.BatchSize")); + if (att.IsBatchSizeSet) + { + Assert.Equal(att.BatchSize, templateWriter.GetToken($"{eventPropertiesPath}.BatchSize")); + } + + Assert.Equal(att.IsEnabledSet, templateWriter.Exists($"{eventPropertiesPath}.Enabled")); + if (att.IsEnabledSet) + { + Assert.Equal(att.Enabled, templateWriter.GetToken($"{eventPropertiesPath}.Enabled")); + } + + Assert.Equal(att.IsFiltersSet, templateWriter.Exists($"{eventPropertiesPath}.FilterCriteria")); + if (att.IsFiltersSet) + { + var filtersList = templateWriter.GetToken>>($"{eventPropertiesPath}.FilterCriteria.Filters"); + var index = 0; + foreach (var filter in att.Filters.Split(';').Select(x => x.Trim())) + { + Assert.Equal(filter, filtersList[index]["Pattern"]); + index++; + } + } + + Assert.Equal(att.IsMaximumBatchingWindowInSecondsSet, templateWriter.Exists($"{eventPropertiesPath}.MaximumBatchingWindowInSeconds")); + if (att.IsMaximumBatchingWindowInSecondsSet) + { + Assert.Equal(att.MaximumBatchingWindowInSeconds, templateWriter.GetToken($"{eventPropertiesPath}.MaximumBatchingWindowInSeconds")); + } + } + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void VerifyDynamoDBEventProperties_AreSyncedCorrectly(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel(); + lambdaFunctionModel.PackageType = LambdaPackageType.Zip; + var eventResourceName = "MyDynamoDBEvent"; + var eventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Properties.Events.{eventResourceName}.Properties"; + var syncedEventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Metadata.SyncedEventProperties"; + + var initialAttribute = new DynamoDBEventAttribute(streamArn1) + { + ResourceName = eventResourceName, + MaximumBatchingWindowInSeconds = 15 + }; + lambdaFunctionModel.Attributes = [new AttributeModel { Data = initialAttribute }]; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport([lambdaFunctionModel]); + + // ACT + cloudFormationWriter.ApplyReport(report); + + // Assert initial properties + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + + Assert.Equal(15, templateWriter.GetToken($"{eventPropertiesPath}.MaximumBatchingWindowInSeconds")); + Assert.Equal(streamArn1, templateWriter.GetToken($"{eventPropertiesPath}.Stream")); + Assert.Equal("LATEST", templateWriter.GetToken($"{eventPropertiesPath}.StartingPosition")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.BatchSize")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.Enabled")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.FilterCriteria")); + + var syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Equal(3, syncedEventProperties[eventResourceName].Count); + Assert.Contains("Stream", syncedEventProperties[eventResourceName]); + Assert.Contains("StartingPosition", syncedEventProperties[eventResourceName]); + Assert.Contains("MaximumBatchingWindowInSeconds", syncedEventProperties[eventResourceName]); + + // Update attribute + var updatedAttribute = new DynamoDBEventAttribute(streamArn2) + { + ResourceName = eventResourceName, + BatchSize = 10 + }; + lambdaFunctionModel.Attributes = [new AttributeModel { Data = updatedAttribute }]; + report = GetAnnotationReport([lambdaFunctionModel]); + cloudFormationWriter.ApplyReport(report); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal(10, templateWriter.GetToken($"{eventPropertiesPath}.BatchSize")); + Assert.Equal(streamArn2, templateWriter.GetToken($"{eventPropertiesPath}.Stream")); + Assert.Equal("LATEST", templateWriter.GetToken($"{eventPropertiesPath}.StartingPosition")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.MaximumBatchingWindowInSeconds")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.Enabled")); + + syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Equal(3, syncedEventProperties[eventResourceName].Count); + Assert.Contains("BatchSize", syncedEventProperties[eventResourceName]); + Assert.Contains("Stream", syncedEventProperties[eventResourceName]); + Assert.Contains("StartingPosition", syncedEventProperties[eventResourceName]); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void SwitchBetweenArnAndRef_ForDynamoDBStream(CloudFormationTemplateFormat templateFormat) + { + // Arrange + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + var mockFileManager = GetMockFileManager(string.Empty); + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + + var lambdaFunctionModel = GetLambdaFunctionModel(); + var eventResourceName = "MyDynamoDBEvent"; + + var syncedEventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Metadata.SyncedEventProperties"; + var eventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Properties.Events.{eventResourceName}.Properties"; + + // Start with Stream ARN + var dynamoDBEventAttribute = new DynamoDBEventAttribute(streamArn1) { ResourceName = eventResourceName }; + lambdaFunctionModel.Attributes = [new AttributeModel { Data = dynamoDBEventAttribute }]; + + // Act + var report = GetAnnotationReport([lambdaFunctionModel]); + cloudFormationWriter.ApplyReport(report); + + // Assert - Stream as ARN + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + var syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + + Assert.Equal(streamArn1, templateWriter.GetToken($"{eventPropertiesPath}.Stream")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.Stream.Fn::GetAtt")); + Assert.Contains("Stream", syncedEventProperties[eventResourceName]); + + // Switch to Stream reference + dynamoDBEventAttribute.Stream = "@MyTable"; + cloudFormationWriter.ApplyReport(report); + + // Assert - Stream as Ref + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + + Assert.Equal(["MyTable", "StreamArn"], templateWriter.GetToken>($"{eventPropertiesPath}.Stream.Fn::GetAtt")); + Assert.Contains("Stream.Fn::GetAtt", syncedEventProperties[eventResourceName]); + } + + public class DynamoDBEventsTestData : TheoryData> + { + public DynamoDBEventsTestData() + { + foreach (var templateFormat in new List { CloudFormationTemplateFormat.Json, CloudFormationTemplateFormat.Yaml }) + { + // Simple attribute + Add(templateFormat, [new(streamArn1)]); + + // Multiple DynamoDBEvent attributes + Add(templateFormat, [new(streamArn1), new(streamArn2)]); + + // Use table reference + Add(templateFormat, [new("@MyTable")]); + + // Specify filters + Add(templateFormat, [new(streamArn1) { Filters = "SOME-FILTER1; SOME-FILTER2" }]); + + // Explicitly specify all properties + Add(templateFormat, + [new(streamArn1) + { + BatchSize = 10, + Filters = "SOME-FILTER1; SOME-FILTER2", + MaximumBatchingWindowInSeconds = 15, + Enabled = false, + StartingPosition = "TRIM_HORIZON" + }]); + } + } + } + } +} diff --git a/Libraries/test/TestServerlessApp.IntegrationTests/DynamoDBEventSourceMapping.cs b/Libraries/test/TestServerlessApp.IntegrationTests/DynamoDBEventSourceMapping.cs new file mode 100644 index 000000000..8dc33a966 --- /dev/null +++ b/Libraries/test/TestServerlessApp.IntegrationTests/DynamoDBEventSourceMapping.cs @@ -0,0 +1,38 @@ +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace TestServerlessApp.IntegrationTests +{ + [Collection("Integration Tests")] + public class DynamoDBEventSourceMapping + { + private readonly IntegrationTestContextFixture _fixture; + + public DynamoDBEventSourceMapping(IntegrationTestContextFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task VerifyDynamoDBEventSourceMappingConfiguration() + { + var lambdaFunctionName = _fixture.LambdaFunctions.FirstOrDefault(x => string.Equals(x.LogicalId, "DynamoDBStreamHandler"))?.Name; + Assert.NotNull(lambdaFunctionName); + + var testTableStreamArn = _fixture.TestTableStreamARN; + Assert.False(string.IsNullOrEmpty(testTableStreamArn), "TestTable stream ARN should not be empty"); + + var listEventSourceMappingResponse = await _fixture.LambdaHelper.ListEventSourceMappingsAsync(lambdaFunctionName, testTableStreamArn); + var eventSourceMappings = listEventSourceMappingResponse.EventSourceMappings; + + Assert.Single(eventSourceMappings); + + var dynamoDbEventSourceMapping = eventSourceMappings.First(); + + Assert.Equal(testTableStreamArn, dynamoDbEventSourceMapping.EventSourceArn); + Assert.Equal(100, dynamoDbEventSourceMapping.BatchSize); + Assert.Equal("TRIM_HORIZON", dynamoDbEventSourceMapping.StartingPosition); + } + } +} diff --git a/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs b/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs index d2c1817cd..5199dae0b 100644 --- a/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs +++ b/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs @@ -35,6 +35,7 @@ public class IntegrationTestContextFixture : IAsyncLifetime public string HttpApiUrlPrefix; public string FunctionUrlPrefix; public string TestTopicARN; + public string TestTableStreamARN; public string TestQueueARN; public string TestS3BucketName; public List LambdaFunctions; @@ -90,6 +91,17 @@ public async Task InitializeAsync() TestTopicARN = await _cloudFormationHelper.GetResourcePhysicalIdAsync(_stackName, "TestTopic"); Console.WriteLine($"[IntegrationTest] TestTopic ARN: {TestTopicARN}"); + // Get the DynamoDB table stream ARN + var testTableName = await _cloudFormationHelper.GetResourcePhysicalIdAsync(_stackName, "TestTable"); + Console.WriteLine($"[IntegrationTest] TestTable: {testTableName}"); + if (!string.IsNullOrEmpty(testTableName)) + { + using var dynamoDbClient = new Amazon.DynamoDBv2.AmazonDynamoDBClient(Amazon.RegionEndpoint.USWest2); + var describeTableResponse = await dynamoDbClient.DescribeTableAsync(testTableName); + TestTableStreamARN = describeTableResponse.Table.LatestStreamArn; + Console.WriteLine($"[IntegrationTest] TestTable Stream ARN: {TestTableStreamARN}"); + } + // Get the S3 bucket name from the physical resource ID TestS3BucketName = await _cloudFormationHelper.GetResourcePhysicalIdAsync(_stackName, "TestS3Bucket"); Console.WriteLine($"[IntegrationTest] TestS3Bucket: {TestS3BucketName}"); @@ -99,7 +111,7 @@ public async Task InitializeAsync() Console.WriteLine($"[IntegrationTest] Found {LambdaFunctions.Count} Lambda functions: {string.Join(", ", LambdaFunctions.Select(f => f.Name ?? "(null)"))}"); Assert.True(await _s3Helper.BucketExistsAsync(_bucketName), $"S3 bucket {_bucketName} should exist"); - Assert.Equal(39, LambdaFunctions.Count); + Assert.Equal(40, LambdaFunctions.Count); Assert.False(string.IsNullOrEmpty(RestApiUrlPrefix), "RestApiUrlPrefix should not be empty"); Assert.False(string.IsNullOrEmpty(HttpApiUrlPrefix), "HttpApiUrlPrefix should not be empty"); diff --git a/Libraries/test/TestServerlessApp.IntegrationTests/TestServerlessApp.IntegrationTests.csproj b/Libraries/test/TestServerlessApp.IntegrationTests/TestServerlessApp.IntegrationTests.csproj index 38aa5bd22..32137f90d 100644 --- a/Libraries/test/TestServerlessApp.IntegrationTests/TestServerlessApp.IntegrationTests.csproj +++ b/Libraries/test/TestServerlessApp.IntegrationTests/TestServerlessApp.IntegrationTests.csproj @@ -8,6 +8,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 9875acee9..a9ce36791 100644 --- a/Libraries/test/TestServerlessApp/TestServerlessApp.csproj +++ b/Libraries/test/TestServerlessApp/TestServerlessApp.csproj @@ -27,6 +27,7 @@ + From 92603243d350826211442b60840be97d346158fd Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Fri, 17 Apr 2026 10:55:08 -0400 Subject: [PATCH 2/8] update --- .../TestServerlessApp.IntegrationTests.csproj | 2 +- .../TestServerlessApp/serverless.template | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/Libraries/test/TestServerlessApp.IntegrationTests/TestServerlessApp.IntegrationTests.csproj b/Libraries/test/TestServerlessApp.IntegrationTests/TestServerlessApp.IntegrationTests.csproj index 32137f90d..0c882dda8 100644 --- a/Libraries/test/TestServerlessApp.IntegrationTests/TestServerlessApp.IntegrationTests.csproj +++ b/Libraries/test/TestServerlessApp.IntegrationTests/TestServerlessApp.IntegrationTests.csproj @@ -9,7 +9,7 @@ - + diff --git a/Libraries/test/TestServerlessApp/serverless.template b/Libraries/test/TestServerlessApp/serverless.template index 65fcfee31..4acce8e42 100644 --- a/Libraries/test/TestServerlessApp/serverless.template +++ b/Libraries/test/TestServerlessApp/serverless.template @@ -1463,6 +1463,51 @@ }, "TestTopic": { "Type": "AWS::SNS::Topic" + }, + "DynamoDBStreamHandler": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "TestTableStream" + ], + "SyncedEventProperties": { + "TestTableStream": [ + "Stream.Fn::GetAtt", + "StartingPosition", + "BatchSize" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaDynamoDBExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.DynamoDbStreamProcessing_HandleStream_Generated::HandleStream" + ] + }, + "Events": { + "TestTableStream": { + "Type": "DynamoDB", + "Properties": { + "Stream": { + "Fn::GetAtt": [ + "TestTable", + "StreamArn" + ] + }, + "StartingPosition": "TRIM_HORIZON", + "BatchSize": 100 + } + } + } + } } } } \ No newline at end of file From 8f0f9ebc99560ac35c280aba81da14c51875ec8d Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Fri, 17 Apr 2026 11:46:17 -0400 Subject: [PATCH 3/8] add tests --- .../DynamoDBEventAttributeBuilder.cs | 5 +- .../Attributes/SQSEventAttributeBuilder.cs | 7 +- .../DynamoDB/DynamoDBEventAttribute.cs | 3 + .../SQS/SQSEventAttribute.cs | 5 +- .../Amazon.Lambda.RuntimeSupport.csproj | 2 +- .../SQSEventAttributeTests.cs | 366 ++++++++++++++++++ .../SourceGeneratorTests.cs | 149 ++++++- .../WriterTests/DynamoDBEventsTests.cs | 123 ++++++ .../WriterTests/SNSEventsTests.cs | 57 +++ .../WriterTests/SQSEventsTests.cs | 5 +- .../src/Function/serverless.template | 28 +- .../DynamoDBEventSourceMapping.cs | 3 + .../SQSEventSourceMapping.cs | 5 +- .../InvalidDynamoDBEvents.cs.error | 68 ++++ .../ValidDynamoDBEvents.cs.txt | 3 + .../InvalidSNSEvents.cs.error | 61 +++ .../InvalidSQSEvents.cs.error | 5 +- .../SQSEventExamples/ValidSQSEvents.cs.txt | 3 + .../aws-lambda-tools-defaults.json | 4 +- .../TestServerlessApp/serverless.template | 6 +- 20 files changed, 868 insertions(+), 40 deletions(-) create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SQSEventAttributeTests.cs create mode 100644 Libraries/test/TestServerlessApp/DynamoDBEventExamples/InvalidDynamoDBEvents.cs.error create mode 100644 Libraries/test/TestServerlessApp/SNSEventExamples/InvalidSNSEvents.cs.error diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DynamoDBEventAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DynamoDBEventAttributeBuilder.cs index ae3affa94..ce0d39c6c 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DynamoDBEventAttributeBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DynamoDBEventAttributeBuilder.cs @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using Amazon.Lambda.Annotations.DynamoDB; using Microsoft.CodeAnalysis; using System; @@ -24,7 +27,7 @@ public static DynamoDBEventAttribute Build(AttributeData att) { data.ResourceName = resourceName; } - if (pair.Key == nameof(data.BatchSize) && pair.Value.Value is uint batchSize) + else if (pair.Key == nameof(data.BatchSize) && pair.Value.Value is uint batchSize) { data.BatchSize = batchSize; } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/SQSEventAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/SQSEventAttributeBuilder.cs index c58063c48..fa1202dc7 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/SQSEventAttributeBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/SQSEventAttributeBuilder.cs @@ -1,4 +1,7 @@ -using Amazon.Lambda.Annotations.SQS; +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.SQS; using Microsoft.CodeAnalysis; using System; @@ -24,7 +27,7 @@ public static SQSEventAttribute Build(AttributeData att) { data.ResourceName = resourceName; } - if (pair.Key == nameof(data.BatchSize) && pair.Value.Value is uint batchSize) + else if (pair.Key == nameof(data.BatchSize) && pair.Value.Value is uint batchSize) { data.BatchSize = batchSize; } diff --git a/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs index 3f078ce21..1c5b8d6f5 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs +++ b/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using System; using System.Collections.Generic; using System.Linq; diff --git a/Libraries/src/Amazon.Lambda.Annotations/SQS/SQSEventAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/SQS/SQSEventAttribute.cs index e3d72464f..8f2ca892e 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/SQS/SQSEventAttribute.cs +++ b/Libraries/src/Amazon.Lambda.Annotations/SQS/SQSEventAttribute.cs @@ -1,4 +1,7 @@ -using System; +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Amazon.Lambda.RuntimeSupport.csproj b/Libraries/src/Amazon.Lambda.RuntimeSupport/Amazon.Lambda.RuntimeSupport.csproj index 6f8dabfa2..0929eb357 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Amazon.Lambda.RuntimeSupport.csproj +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Amazon.Lambda.RuntimeSupport.csproj @@ -3,7 +3,7 @@ - netstandard2.0;net6.0;net8.0;net9.0;net10.0;net11.0 + netstandard2.0;net6.0;net8.0;net9.0;net10.0 1.14.3 Provides a bootstrap and Lambda Runtime API Client to help you to develop custom .NET Core Lambda Runtimes. Amazon.Lambda.RuntimeSupport diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SQSEventAttributeTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SQSEventAttributeTests.cs new file mode 100644 index 000000000..bf57c36c0 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SQSEventAttributeTests.cs @@ -0,0 +1,366 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.SQS; +using Xunit; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests +{ + public class SQSEventAttributeTests + { + // ===== Constructor and Default Values ===== + + [Fact] + public void Constructor_SetsQueueProperty() + { + var attr = new SQSEventAttribute("@MyQueue"); + + Assert.Equal("@MyQueue", attr.Queue); + } + + [Fact] + public void DefaultValues_AreCorrect() + { + var attr = new SQSEventAttribute("@MyQueue"); + + Assert.False(attr.IsBatchSizeSet); + Assert.False(attr.IsEnabledSet); + Assert.False(attr.IsMaximumBatchingWindowInSecondsSet); + Assert.False(attr.IsMaximumConcurrencySet); + Assert.Null(attr.Filters); + Assert.False(attr.IsFiltersSet); + Assert.False(attr.IsResourceNameSet); + Assert.Equal("MyQueue", attr.ResourceName); + } + + // ===== ResourceName Tests ===== + + [Fact] + public void ResourceName_DerivedFromQueue_WithAtPrefix() + { + var attr = new SQSEventAttribute("@TestQueue"); + + Assert.False(attr.IsResourceNameSet); + Assert.Equal("TestQueue", attr.ResourceName); + } + + [Fact] + public void ResourceName_DerivedFromQueueArn() + { + var attr = new SQSEventAttribute("arn:aws:sqs:us-east-1:123456789012:MyQueue"); + + Assert.False(attr.IsResourceNameSet); + Assert.Equal("MyQueue", attr.ResourceName); + } + + [Fact] + public void ResourceName_WhenExplicitlySet_IsTracked() + { + var attr = new SQSEventAttribute("@MyQueue"); + + Assert.False(attr.IsResourceNameSet); + + attr.ResourceName = "CustomEventName"; + Assert.True(attr.IsResourceNameSet); + Assert.Equal("CustomEventName", attr.ResourceName); + } + + // ===== BatchSize Tests ===== + + [Fact] + public void BatchSize_DefaultNotSet() + { + var attr = new SQSEventAttribute("@MyQueue"); + + Assert.False(attr.IsBatchSizeSet); + } + + [Fact] + public void BatchSize_WhenSet_IsTracked() + { + var attr = new SQSEventAttribute("@MyQueue") + { + BatchSize = 50 + }; + + Assert.True(attr.IsBatchSizeSet); + Assert.Equal((uint)50, attr.BatchSize); + } + + // ===== MaximumBatchingWindowInSeconds Tests ===== + + [Fact] + public void MaximumBatchingWindowInSeconds_DefaultNotSet() + { + var attr = new SQSEventAttribute("@MyQueue"); + + Assert.False(attr.IsMaximumBatchingWindowInSecondsSet); + } + + [Fact] + public void MaximumBatchingWindowInSeconds_WhenSet_IsTracked() + { + var attr = new SQSEventAttribute("@MyQueue") + { + MaximumBatchingWindowInSeconds = 60 + }; + + Assert.True(attr.IsMaximumBatchingWindowInSecondsSet); + Assert.Equal((uint)60, attr.MaximumBatchingWindowInSeconds); + } + + // ===== MaximumConcurrency Tests ===== + + [Fact] + public void MaximumConcurrency_DefaultNotSet() + { + var attr = new SQSEventAttribute("@MyQueue"); + + Assert.False(attr.IsMaximumConcurrencySet); + } + + [Fact] + public void MaximumConcurrency_WhenSet_IsTracked() + { + var attr = new SQSEventAttribute("@MyQueue") + { + MaximumConcurrency = 10 + }; + + Assert.True(attr.IsMaximumConcurrencySet); + Assert.Equal((uint)10, attr.MaximumConcurrency); + } + + // ===== Enabled Property Tests ===== + + [Fact] + public void Enabled_DefaultNotSet() + { + var attr = new SQSEventAttribute("@MyQueue"); + + Assert.False(attr.IsEnabledSet); + } + + [Fact] + public void Enabled_WhenSetToFalse_IsTracked() + { + var attr = new SQSEventAttribute("@MyQueue") + { + Enabled = false + }; + + Assert.True(attr.IsEnabledSet); + Assert.False(attr.Enabled); + } + + [Fact] + public void Enabled_WhenSetToTrue_IsTracked() + { + var attr = new SQSEventAttribute("@MyQueue") + { + Enabled = true + }; + + Assert.True(attr.IsEnabledSet); + Assert.True(attr.Enabled); + } + + // ===== Filters Tests ===== + + [Fact] + public void Filters_DefaultIsNull() + { + var attr = new SQSEventAttribute("@MyQueue"); + + Assert.Null(attr.Filters); + Assert.False(attr.IsFiltersSet); + } + + [Fact] + public void Filters_WhenSet_IsTracked() + { + var attr = new SQSEventAttribute("@MyQueue") + { + Filters = "{ \"body\" : { \"RequestCode\" : [ \"BBBB\" ] } }" + }; + + Assert.True(attr.IsFiltersSet); + Assert.Equal("{ \"body\" : { \"RequestCode\" : [ \"BBBB\" ] } }", attr.Filters); + } + + // ===== Validation Tests ===== + + [Fact] + public void Validate_ValidResourceReference_ReturnsNoErrors() + { + var attr = new SQSEventAttribute("@MyQueue"); + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_ValidQueueArn_ReturnsNoErrors() + { + var attr = new SQSEventAttribute("arn:aws:sqs:us-east-1:123456789012:MyQueue"); + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_InvalidQueueArn_ReturnsError() + { + var attr = new SQSEventAttribute("not-a-valid-arn"); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("Queue", errors[0]); + Assert.Contains("ARN", errors[0]); + } + + [Fact] + public void Validate_InvalidResourceName_ReturnsError() + { + var attr = new SQSEventAttribute("@MyQueue") + { + ResourceName = "invalid-name!" + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("ResourceName", errors[0]); + Assert.Contains("alphanumeric", errors[0]); + } + + [Fact] + public void Validate_BatchSizeTooLarge_ReturnsError() + { + var attr = new SQSEventAttribute("@MyQueue") + { + BatchSize = 10001 + }; + + var errors = attr.Validate(); + Assert.Contains(errors, e => e.Contains("BatchSize")); + } + + [Fact] + public void Validate_BatchSizeZero_ReturnsError() + { + var attr = new SQSEventAttribute("@MyQueue") + { + BatchSize = 0 + }; + + var errors = attr.Validate(); + Assert.Contains(errors, e => e.Contains("BatchSize")); + } + + [Fact] + public void Validate_MaxBatchingWindowTooLarge_ReturnsError() + { + var attr = new SQSEventAttribute("@MyQueue") + { + MaximumBatchingWindowInSeconds = 301 + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("MaximumBatchingWindowInSeconds", errors[0]); + } + + [Fact] + public void Validate_MaximumConcurrencyTooLow_ReturnsError() + { + var attr = new SQSEventAttribute("@MyQueue") + { + MaximumConcurrency = 1 + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("MaximumConcurrency", errors[0]); + } + + [Fact] + public void Validate_MaximumConcurrencyTooHigh_ReturnsError() + { + var attr = new SQSEventAttribute("@MyQueue") + { + MaximumConcurrency = 1001 + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("MaximumConcurrency", errors[0]); + } + + [Fact] + public void Validate_BatchSizeGreaterThan10_RequiresMaximumBatchingWindow() + { + var attr = new SQSEventAttribute("@MyQueue") + { + BatchSize = 100 + }; + + var errors = attr.Validate(); + Assert.Contains(errors, e => e.Contains("MaximumBatchingWindowInSeconds")); + } + + [Fact] + public void Validate_FifoQueue_MaximumBatchingWindowNotAllowed() + { + var attr = new SQSEventAttribute("arn:aws:sqs:us-east-2:444455556666:test-queue.fifo") + { + MaximumBatchingWindowInSeconds = 5 + }; + + var errors = attr.Validate(); + Assert.Contains(errors, e => e.Contains("FIFO")); + } + + [Fact] + public void Validate_FifoQueue_BatchSizeGreaterThan10NotAllowed() + { + var attr = new SQSEventAttribute("arn:aws:sqs:us-east-2:444455556666:test-queue.fifo") + { + BatchSize = 100, + MaximumBatchingWindowInSeconds = 5 + }; + + var errors = attr.Validate(); + Assert.Contains(errors, e => e.Contains("FIFO") && e.Contains("BatchSize")); + } + + [Fact] + public void Validate_EmptyResourceName_ReturnsError() + { + var attr = new SQSEventAttribute("@MyQueue") + { + ResourceName = "" + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("ResourceName", errors[0]); + } + + [Fact] + public void Validate_AllValidWithOptionals_ReturnsNoErrors() + { + var attr = new SQSEventAttribute("@MyQueue") + { + ResourceName = "MySQSEvent", + BatchSize = 5, + MaximumBatchingWindowInSeconds = 60, + MaximumConcurrency = 30, + Filters = "{ \"body\" : { \"RequestCode\" : [ \"BBBB\" ] } }", + Enabled = true + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs index f6d5d1c94..1c8c18171 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs @@ -1227,59 +1227,59 @@ public async Task VerifyInvalidSQSEvents_ThrowsCompilationErrors() ExpectedDiagnostics = { DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 15, 9, 20, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 18, 9, 23, 10) .WithArguments("BatchSize = 0. It must be between 1 and 10000"), DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 15, 9, 20, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 18, 9, 23, 10) .WithArguments("MaximumBatchingWindowInSeconds = 302. It must be between 0 and 300"), DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 15, 9, 20, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 18, 9, 23, 10) .WithArguments("MaximumConcurrency = 1. It must be between 2 and 1000"), DiagnosticResult.CompilerError("AWSLambda0117") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 22, 9, 27, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 25, 9, 30, 10) .WithArguments("When using the SQSEventAttribute, the Lambda method can accept at most 2 parameters. " + "The first parameter is required and must be of type Amazon.Lambda.SQSEvents.SQSEvent. " + "The second parameter is optional and must be of type Amazon.Lambda.Core.ILambdaContext."), DiagnosticResult.CompilerError("AWSLambda0117") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 29, 9, 35, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 32, 9, 38, 10) .WithArguments("When using the SQSEventAttribute, the Lambda method can return either " + "void, System.Threading.Tasks.Task, Amazon.Lambda.SQSEvents.SQSBatchResponse or Task"), DiagnosticResult .CompilerError("AWSLambda0102") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 37, 9, 43, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 40, 9, 46, 10) .WithMessage("Multiple event attributes on LambdaFunction are not supported"), DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 45, 9, 50, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 48, 9, 53, 10) .WithArguments("Queue = test-queue. The SQS queue ARN is invalid. The ARN format is 'arn::sqs:::'"), DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 52, 9, 57, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 55, 9, 60, 10) .WithArguments("ResourceName = sqs-event-source. It must only contain alphanumeric characters and must not be an empty string"), DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 59, 9, 64, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 62, 9, 67, 10) .WithArguments("ResourceName = . It must only contain alphanumeric characters and must not be an empty string"), DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 66, 9, 71, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 69, 9, 74, 10) .WithArguments("MaximumBatchingWindowInSeconds is not set or set to a value less than 1. It must be set to at least 1 when BatchSize is greater than 10"), DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 73, 9, 78, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 76, 9, 81, 10) .WithArguments("MaximumBatchingWindowInSeconds is not set or set to a value less than 1. It must be set to at least 1 when BatchSize is greater than 10"), DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 80, 9, 85, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 83, 9, 88, 10) .WithArguments("BatchSize = 100. It must be less than or equal to 10 when the event source mapping is for a FIFO queue"), DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 80, 9, 85, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 83, 9, 88, 10) .WithArguments("MaximumBatchingWindowInSeconds must not be set when the event source mapping is for a FIFO queue") } } @@ -1391,6 +1391,75 @@ public async Task VerifyValidALBEvents() }.RunAsync(); } + [Fact] + public async Task VerifyInvalidDynamoDBEvents_ThrowsCompilationErrors() + { + await new VerifyCS.Test + { + TestState = + { + Sources = + { + (Path.Combine("TestServerlessApp", "PlaceholderClass.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "PlaceholderClass.cs"))), + (Path.Combine("TestServerlessApp", "DynamoDBEventExamples", "InvalidDynamoDBEvents.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "DynamoDBEventExamples", "InvalidDynamoDBEvents.cs.error"))), + (Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"))), + (Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "DynamoDBEventAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "DynamoDBEventAttribute.cs"))), + (Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"))), + }, + ExpectedDiagnostics = + { + // ProcessMessageWithInvalidDynamoDBEventAttributes: BatchSize = 10001 + DiagnosticResult.CompilerError("AWSLambda0137") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 17, 9, 22, 10) + .WithArguments("BatchSize = 10001. It must be between 1 and 10000"), + + // ProcessMessageWithInvalidDynamoDBEventAttributes: MaximumBatchingWindowInSeconds = 301 + DiagnosticResult.CompilerError("AWSLambda0137") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 17, 9, 22, 10) + .WithArguments("MaximumBatchingWindowInSeconds = 301. It must be between 0 and 300"), + + // ProcessMessageWithInvalidDynamoDBEventAttributes: StartingPosition = INVALID + DiagnosticResult.CompilerError("AWSLambda0137") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 17, 9, 22, 10) + .WithArguments("StartingPosition = INVALID. It must be either TRIM_HORIZON or LATEST"), + + // ProcessMessageWithInvalidParameters: too many parameters + DiagnosticResult.CompilerError("AWSLambda0117") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 24, 9, 29, 10) + .WithArguments("When using the DynamoDBEventAttribute, the Lambda method can accept at most 2 parameters. " + + "The first parameter is required and must be of type Amazon.Lambda.DynamoDBEvents.DynamoDBEvent. " + + "The second parameter is optional and must be of type Amazon.Lambda.Core.ILambdaContext."), + + // ProcessMessageWithInvalidReturnType: returns bool + DiagnosticResult.CompilerError("AWSLambda0117") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 31, 9, 37, 10) + .WithArguments("When using the DynamoDBEventAttribute, the Lambda method can return either void or System.Threading.Tasks.Task"), + + // ProcessMessageWithMultipleEventTypes: multiple events + DiagnosticResult + .CompilerError("AWSLambda0102") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 39, 9, 45, 10) + .WithMessage("Multiple event attributes on LambdaFunction are not supported"), + + // ProcessMessageWithInvalidStreamArn: invalid ARN + DiagnosticResult.CompilerError("AWSLambda0137") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 47, 9, 52, 10) + .WithArguments("Stream = not-a-valid-arn. The DynamoDB stream ARN is invalid"), + + // ProcessMessageWithInvalidResourceName: invalid characters + DiagnosticResult.CompilerError("AWSLambda0137") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 54, 9, 59, 10) + .WithArguments("ResourceName = dynamo-event-source. It must only contain alphanumeric characters and must not be an empty string"), + + // ProcessMessageWithEmptyResourceName: empty string + DiagnosticResult.CompilerError("AWSLambda0137") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 61, 9, 66, 10) + .WithArguments("ResourceName = . It must only contain alphanumeric characters and must not be an empty string"), + } + } + }.RunAsync(); + } + [Fact] public async Task VerifyValidDynamoDBEvents() { @@ -1438,6 +1507,60 @@ public async Task VerifyValidDynamoDBEvents() }.RunAsync(); } + [Fact] + public async Task VerifyInvalidSNSEvents_ThrowsCompilationErrors() + { + await new VerifyCS.Test + { + TestState = + { + Sources = + { + (Path.Combine("TestServerlessApp", "PlaceholderClass.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "PlaceholderClass.cs"))), + (Path.Combine("TestServerlessApp", "SNSEventExamples", "InvalidSNSEvents.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "SNSEventExamples", "InvalidSNSEvents.cs.error"))), + (Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"))), + (Path.Combine("Amazon.Lambda.Annotations", "SNS", "SNSEventAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "SNS", "SNSEventAttribute.cs"))), + (Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"))), + }, + ExpectedDiagnostics = + { + // ProcessMessageWithInvalidTopicArn: invalid ARN + DiagnosticResult.CompilerError("AWSLambda0138") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SNSEventExamples{Path.DirectorySeparatorChar}InvalidSNSEvents.cs", 17, 9, 22, 10) + .WithArguments("Topic = not-a-valid-arn. The SNS topic ARN is invalid. The ARN format is 'arn::sns:::'"), + + // ProcessMessageWithInvalidParameters: too many parameters + DiagnosticResult.CompilerError("AWSLambda0117") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SNSEventExamples{Path.DirectorySeparatorChar}InvalidSNSEvents.cs", 24, 9, 29, 10) + .WithArguments("When using the SNSEventAttribute, the Lambda method can accept at most 2 parameters. " + + "The first parameter is required and must be of type Amazon.Lambda.SNSEvents.SNSEvent. " + + "The second parameter is optional and must be of type Amazon.Lambda.Core.ILambdaContext."), + + // ProcessMessageWithInvalidReturnType: returns bool + DiagnosticResult.CompilerError("AWSLambda0117") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SNSEventExamples{Path.DirectorySeparatorChar}InvalidSNSEvents.cs", 31, 9, 37, 10) + .WithArguments("When using the SNSEventAttribute, the Lambda method can return either void or System.Threading.Tasks.Task"), + + // ProcessMessageWithMultipleEventTypes: multiple events + DiagnosticResult + .CompilerError("AWSLambda0102") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SNSEventExamples{Path.DirectorySeparatorChar}InvalidSNSEvents.cs", 39, 9, 45, 10) + .WithMessage("Multiple event attributes on LambdaFunction are not supported"), + + // ProcessMessageWithInvalidResourceName: invalid characters + DiagnosticResult.CompilerError("AWSLambda0138") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SNSEventExamples{Path.DirectorySeparatorChar}InvalidSNSEvents.cs", 47, 9, 52, 10) + .WithArguments("ResourceName = sns-event-source. It must only contain alphanumeric characters and must not be an empty string"), + + // ProcessMessageWithEmptyResourceName: empty string + DiagnosticResult.CompilerError("AWSLambda0138") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SNSEventExamples{Path.DirectorySeparatorChar}InvalidSNSEvents.cs", 54, 9, 59, 10) + .WithArguments("ResourceName = . It must only contain alphanumeric characters and must not be an empty string"), + } + } + }.RunAsync(); + } + [Fact] public async Task VerifyValidSNSEvents() { diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/DynamoDBEventsTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/DynamoDBEventsTests.cs index 1696edd28..c9d21fb8a 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/DynamoDBEventsTests.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/DynamoDBEventsTests.cs @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using Amazon.Lambda.Annotations.SourceGenerator; using Amazon.Lambda.Annotations.SourceGenerator.Models; using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; @@ -199,6 +202,126 @@ public void SwitchBetweenArnAndRef_ForDynamoDBStream(CloudFormationTemplateForma Assert.Contains("Stream.Fn::GetAtt", syncedEventProperties[eventResourceName]); } + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void VerifyStreamCanBeSet_FromCloudFormationParameter(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE + const string jsonContent = @"{ + 'Parameters':{ + 'MyTable':{ + 'Type':'String', + 'Default':'arn:aws:dynamodb:us-east-2:444455556666:table/MyTable/stream/2024-01-01T00:00:00' + } + } + }"; + + const string yamlContent = @"Parameters: + MyTable: + Type: String + Default: arn:aws:dynamodb:us-east-2:444455556666:table/MyTable/stream/2024-01-01T00:00:00"; + + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + var content = templateFormat == CloudFormationTemplateFormat.Json ? jsonContent : yamlContent; + + var mockFileManager = GetMockFileManager(content); + var lambdaFunctionModel = GetLambdaFunctionModel(); + var eventResourceName = "MyDynamoDBEvent"; + var dynamoDBEventAttribute = new DynamoDBEventAttribute("@MyTable") { ResourceName = eventResourceName }; + lambdaFunctionModel.Attributes = [new AttributeModel { Data = dynamoDBEventAttribute }]; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport([lambdaFunctionModel]); + + var eventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Properties.Events.{eventResourceName}.Properties"; + var syncedEventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Metadata.SyncedEventProperties"; + + // ACT + cloudFormationWriter.ApplyReport(report); + + // Verify Stream property exists as a Ref (when @name matches a CF Parameter, writer uses Ref) + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal("MyTable", templateWriter.GetToken($"{eventPropertiesPath}.Stream.Ref")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.Stream.Fn::GetAtt")); + + // Verify the list of synced event properties + var syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Contains("Stream.Ref", syncedEventProperties[eventResourceName]); + + // Change the Stream property to be an ARN and re-generate the template + dynamoDBEventAttribute.Stream = streamArn1; + lambdaFunctionModel.Attributes = [new AttributeModel { Data = dynamoDBEventAttribute }]; + report = GetAnnotationReport([lambdaFunctionModel]); + cloudFormationWriter.ApplyReport(report); + + // Verify Stream property exists as an ARN + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal(streamArn1, templateWriter.GetToken($"{eventPropertiesPath}.Stream")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.Stream.Fn::GetAtt")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.Stream.Ref")); + + // Verify the list of synced event properties + syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Contains("Stream", syncedEventProperties[eventResourceName]); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void VerifyManuallySetDynamoDBEventProperties_ArePreserved(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel(); + lambdaFunctionModel.PackageType = LambdaPackageType.Zip; + var eventResourceName = "MyDynamoDBEvent"; + var eventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Properties.Events.{eventResourceName}.Properties"; + var syncedEventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Metadata.SyncedEventProperties"; + + var initialAttribute = new DynamoDBEventAttribute(streamArn1) + { + ResourceName = eventResourceName, + BatchSize = 20 + }; + lambdaFunctionModel.Attributes = [new AttributeModel { Data = initialAttribute }]; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport([lambdaFunctionModel]); + + // ACT + cloudFormationWriter.ApplyReport(report); + + // Assert that initial attributes properties are correctly set + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + + Assert.Equal(20, templateWriter.GetToken($"{eventPropertiesPath}.BatchSize")); + Assert.Equal(streamArn1, templateWriter.GetToken($"{eventPropertiesPath}.Stream")); + + // Verify initial attribute properties are synced + var syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Contains("BatchSize", syncedEventProperties[eventResourceName]); + Assert.Contains("Stream", syncedEventProperties[eventResourceName]); + + // Modify the serverless template by hand and add a new property + templateWriter.SetToken($"{eventPropertiesPath}.MaximumBatchingWindowInSeconds", 30); + mockFileManager.WriteAllText(ServerlessTemplateFilePath, templateWriter.GetContent()); + + // Perform another source generation + cloudFormationWriter.ApplyReport(report); + + // Assert that both the initial properties and the manually added property exists + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal(20, templateWriter.GetToken($"{eventPropertiesPath}.BatchSize")); + Assert.Equal(streamArn1, templateWriter.GetToken($"{eventPropertiesPath}.Stream")); + Assert.Equal(30, templateWriter.GetToken($"{eventPropertiesPath}.MaximumBatchingWindowInSeconds")); + + // Assert that the synced event properties are still the same and the manually set property is not synced + syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Contains("BatchSize", syncedEventProperties[eventResourceName]); + Assert.Contains("Stream", syncedEventProperties[eventResourceName]); + Assert.DoesNotContain("MaximumBatchingWindowInSeconds", syncedEventProperties[eventResourceName]); + } + public class DynamoDBEventsTestData : TheoryData> { public DynamoDBEventsTestData() diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/SNSEventsTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/SNSEventsTests.cs index 59b94bee1..caf7f94fd 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/SNSEventsTests.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/SNSEventsTests.cs @@ -225,6 +225,63 @@ public void SwitchBetweenArnAndRef_ForTopic(CloudFormationTemplateFormat templat Assert.Equal("Topic.Ref", syncedEventProperties[eventResourceName][0]); } + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void VerifyManuallySetSNSEventProperties_ArePreserved(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel(); + lambdaFunctionModel.PackageType = LambdaPackageType.Zip; + var eventResourceName = "MySNSEvent"; + var eventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Properties.Events.{eventResourceName}.Properties"; + var syncedEventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Metadata.SyncedEventProperties"; + + var initialAttribute = new SNSEventAttribute(topicArn1) + { + ResourceName = eventResourceName, + FilterPolicy = "{ \"store\": [\"example_corp\"] }" + }; + lambdaFunctionModel.Attributes = [new AttributeModel { Data = initialAttribute }]; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport([lambdaFunctionModel]); + + // ACT + cloudFormationWriter.ApplyReport(report); + + // Assert that initial attributes properties are correctly set + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + + Assert.Equal(topicArn1, templateWriter.GetToken($"{eventPropertiesPath}.Topic")); + Assert.Equal("{ \"store\": [\"example_corp\"] }", templateWriter.GetToken($"{eventPropertiesPath}.FilterPolicy")); + + // Verify initial attribute properties are synced + var syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Contains("Topic", syncedEventProperties[eventResourceName]); + Assert.Contains("FilterPolicy", syncedEventProperties[eventResourceName]); + + // Modify the serverless template by hand and add a new property (Enabled) + templateWriter.SetToken($"{eventPropertiesPath}.Enabled", false); + mockFileManager.WriteAllText(ServerlessTemplateFilePath, templateWriter.GetContent()); + + // Perform another source generation + cloudFormationWriter.ApplyReport(report); + + // Assert that both the initial properties and the manually added property exists + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal(topicArn1, templateWriter.GetToken($"{eventPropertiesPath}.Topic")); + Assert.Equal("{ \"store\": [\"example_corp\"] }", templateWriter.GetToken($"{eventPropertiesPath}.FilterPolicy")); + Assert.False(templateWriter.GetToken($"{eventPropertiesPath}.Enabled")); + + // Assert that the synced event properties are still the same and the manually set property is not synced + syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Contains("Topic", syncedEventProperties[eventResourceName]); + Assert.Contains("FilterPolicy", syncedEventProperties[eventResourceName]); + Assert.DoesNotContain("Enabled", syncedEventProperties[eventResourceName]); + } + public class SnsEventsTestData : TheoryData> { public SnsEventsTestData() diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/SQSEventsTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/SQSEventsTests.cs index 94b5bb2b9..4ec53e4e0 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/SQSEventsTests.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/SQSEventsTests.cs @@ -1,4 +1,7 @@ -using Amazon.Lambda.Annotations.SourceGenerator; +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.SourceGenerator; using Amazon.Lambda.Annotations.SourceGenerator.Models; using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; using Amazon.Lambda.Annotations.SourceGenerator.Writers; diff --git a/Libraries/test/TestCustomAuthorizerApp/src/Function/serverless.template b/Libraries/test/TestCustomAuthorizerApp/src/Function/serverless.template index 59a8256fb..ebce2bc30 100644 --- a/Libraries/test/TestCustomAuthorizerApp/src/Function/serverless.template +++ b/Libraries/test/TestCustomAuthorizerApp/src/Function/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.9.0.0).", + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.14.0.0).", "Resources": { "AnnotationsHttpApi": { "Type": "AWS::Serverless::HttpApi", @@ -11,7 +11,7 @@ "Properties": { "Auth": { "Authorizers": { - "HttpApiAuthorize": { + "CustomAuthorizer": { "FunctionArn": { "Fn::GetAtt": [ "CustomAuthorizer", @@ -28,7 +28,7 @@ "EnableFunctionDefaultPermissions": true, "AuthorizerResultTtlInSeconds": 0 }, - "HttpApiAuthorizeV1": { + "CustomAuthorizerV1": { "FunctionArn": { "Fn::GetAtt": [ "CustomAuthorizerV1", @@ -45,7 +45,7 @@ "EnableFunctionDefaultPermissions": true, "AuthorizerResultTtlInSeconds": 0 }, - "SimpleHttpApiAuthorize": { + "SimpleAuthorizer": { "FunctionArn": { "Fn::GetAtt": [ "SimpleAuthorizer", @@ -72,7 +72,7 @@ "StageName": "Prod", "Auth": { "Authorizers": { - "RestApiAuthorize": { + "RestApiAuthorizer": { "FunctionArn": { "Fn::GetAtt": [ "RestApiAuthorizer", @@ -85,7 +85,7 @@ "FunctionPayloadType": "TOKEN", "AuthorizerResultTtlInSeconds": 0 }, - "SimpleRestApiAuthorize": { + "SimpleRestAuthorizer": { "FunctionArn": { "Fn::GetAtt": [ "SimpleRestAuthorizer", @@ -223,7 +223,7 @@ "Path": "/api/protected", "Method": "GET", "Auth": { - "Authorizer": "HttpApiAuthorize" + "Authorizer": "CustomAuthorizer" }, "ApiId": { "Ref": "AnnotationsHttpApi" @@ -266,7 +266,7 @@ "Path": "/api/user-info", "Method": "GET", "Auth": { - "Authorizer": "HttpApiAuthorize" + "Authorizer": "CustomAuthorizer" }, "ApiId": { "Ref": "AnnotationsHttpApi" @@ -348,7 +348,7 @@ "Path": "/api/rest-user-info", "Method": "GET", "Auth": { - "Authorizer": "RestApiAuthorize" + "Authorizer": "RestApiAuthorizer" }, "RestApiId": { "Ref": "AnnotationsRestApi" @@ -393,7 +393,7 @@ "Method": "GET", "PayloadFormatVersion": "1.0", "Auth": { - "Authorizer": "HttpApiAuthorizeV1" + "Authorizer": "CustomAuthorizerV1" }, "ApiId": { "Ref": "AnnotationsHttpApi" @@ -436,7 +436,7 @@ "Path": "/api/ihttpresult-user-info", "Method": "GET", "Auth": { - "Authorizer": "HttpApiAuthorize" + "Authorizer": "CustomAuthorizer" }, "ApiId": { "Ref": "AnnotationsHttpApi" @@ -479,7 +479,7 @@ "Path": "/api/simple-httpapi-user-info", "Method": "GET", "Auth": { - "Authorizer": "SimpleHttpApiAuthorize" + "Authorizer": "SimpleAuthorizer" }, "ApiId": { "Ref": "AnnotationsHttpApi" @@ -522,7 +522,7 @@ "Path": "/api/simple-restapi-user-info", "Method": "GET", "Auth": { - "Authorizer": "SimpleRestApiAuthorize" + "Authorizer": "SimpleRestAuthorizer" }, "RestApiId": { "Ref": "AnnotationsRestApi" @@ -565,7 +565,7 @@ "Path": "/api/nonstring-user-info", "Method": "GET", "Auth": { - "Authorizer": "HttpApiAuthorize" + "Authorizer": "CustomAuthorizer" }, "ApiId": { "Ref": "AnnotationsHttpApi" diff --git a/Libraries/test/TestServerlessApp.IntegrationTests/DynamoDBEventSourceMapping.cs b/Libraries/test/TestServerlessApp.IntegrationTests/DynamoDBEventSourceMapping.cs index 8dc33a966..cef6c76e4 100644 --- a/Libraries/test/TestServerlessApp.IntegrationTests/DynamoDBEventSourceMapping.cs +++ b/Libraries/test/TestServerlessApp.IntegrationTests/DynamoDBEventSourceMapping.cs @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using System.Linq; using System.Threading.Tasks; using Xunit; diff --git a/Libraries/test/TestServerlessApp.IntegrationTests/SQSEventSourceMapping.cs b/Libraries/test/TestServerlessApp.IntegrationTests/SQSEventSourceMapping.cs index e24169f0d..02f803074 100644 --- a/Libraries/test/TestServerlessApp.IntegrationTests/SQSEventSourceMapping.cs +++ b/Libraries/test/TestServerlessApp.IntegrationTests/SQSEventSourceMapping.cs @@ -1,4 +1,7 @@ -using System.Linq; +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Linq; using System.Threading.Tasks; using Xunit; diff --git a/Libraries/test/TestServerlessApp/DynamoDBEventExamples/InvalidDynamoDBEvents.cs.error b/Libraries/test/TestServerlessApp/DynamoDBEventExamples/InvalidDynamoDBEvents.cs.error new file mode 100644 index 000000000..243ddfb7a --- /dev/null +++ b/Libraries/test/TestServerlessApp/DynamoDBEventExamples/InvalidDynamoDBEvents.cs.error @@ -0,0 +1,68 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.Annotations.DynamoDB; +using Amazon.Lambda.DynamoDBEvents; +using System; + +namespace TestServerlessApp.DynamoDBEventExamples +{ + // This file represents invalid usage of the DynamoDBEventAttribute. + // This file is sent as input to the source generator unit tests and we assert that compilation errors are thrown with the appropriate diagnostic message. + + public class InvalidDynamoDBEvents + { + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [DynamoDBEvent("@testTable", BatchSize = 10001, MaximumBatchingWindowInSeconds = 301, StartingPosition = "INVALID")] + public void ProcessMessageWithInvalidDynamoDBEventAttributes(DynamoDBEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [DynamoDBEvent("@testTable")] + public void ProcessMessageWithInvalidParameters(DynamoDBEvent evnt, bool invalidParameter1, int invalidParameter2) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [DynamoDBEvent("@testTable")] + public bool ProcessMessageWithInvalidReturnType(DynamoDBEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + return true; + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [RestApi(LambdaHttpMethod.Get, "/")] + [DynamoDBEvent("@testTable")] + public void ProcessMessageWithMultipleEventTypes(DynamoDBEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [DynamoDBEvent("not-a-valid-arn")] + public void ProcessMessageWithInvalidStreamArn(DynamoDBEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [DynamoDBEvent("@testTable", ResourceName = "dynamo-event-source")] + public void ProcessMessageWithInvalidResourceName(DynamoDBEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [DynamoDBEvent("@testTable", ResourceName = "")] + public void ProcessMessageWithEmptyResourceName(DynamoDBEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + } +} diff --git a/Libraries/test/TestServerlessApp/DynamoDBEventExamples/ValidDynamoDBEvents.cs.txt b/Libraries/test/TestServerlessApp/DynamoDBEventExamples/ValidDynamoDBEvents.cs.txt index a6fd76cda..c10996d5d 100644 --- a/Libraries/test/TestServerlessApp/DynamoDBEventExamples/ValidDynamoDBEvents.cs.txt +++ b/Libraries/test/TestServerlessApp/DynamoDBEventExamples/ValidDynamoDBEvents.cs.txt @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using Amazon.Lambda.Annotations; using Amazon.Lambda.Annotations.DynamoDB; using Amazon.Lambda.DynamoDBEvents; diff --git a/Libraries/test/TestServerlessApp/SNSEventExamples/InvalidSNSEvents.cs.error b/Libraries/test/TestServerlessApp/SNSEventExamples/InvalidSNSEvents.cs.error new file mode 100644 index 000000000..639c31441 --- /dev/null +++ b/Libraries/test/TestServerlessApp/SNSEventExamples/InvalidSNSEvents.cs.error @@ -0,0 +1,61 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.Annotations.SNS; +using Amazon.Lambda.SNSEvents; +using System; + +namespace TestServerlessApp.SNSEventExamples +{ + // This file represents invalid usage of the SNSEventAttribute. + // This file is sent as input to the source generator unit tests and we assert that compilation errors are thrown with the appropriate diagnostic message. + + public class InvalidSNSEvents + { + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [SNSEvent("not-a-valid-arn")] + public void ProcessMessageWithInvalidTopicArn(SNSEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [SNSEvent("@testTopic")] + public void ProcessMessageWithInvalidParameters(SNSEvent evnt, bool invalidParameter1, int invalidParameter2) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [SNSEvent("@testTopic")] + public bool ProcessMessageWithInvalidReturnType(SNSEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + return true; + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [RestApi(LambdaHttpMethod.Get, "/")] + [SNSEvent("@testTopic")] + public void ProcessMessageWithMultipleEventTypes(SNSEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [SNSEvent("@testTopic", ResourceName = "sns-event-source")] + public void ProcessMessageWithInvalidResourceName(SNSEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [SNSEvent("@testTopic", ResourceName = "")] + public void ProcessMessageWithEmptyResourceName(SNSEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + } +} diff --git a/Libraries/test/TestServerlessApp/SQSEventExamples/InvalidSQSEvents.cs.error b/Libraries/test/TestServerlessApp/SQSEventExamples/InvalidSQSEvents.cs.error index 1603f121d..617c758f7 100644 --- a/Libraries/test/TestServerlessApp/SQSEventExamples/InvalidSQSEvents.cs.error +++ b/Libraries/test/TestServerlessApp/SQSEventExamples/InvalidSQSEvents.cs.error @@ -1,4 +1,7 @@ -using Amazon.Lambda.Annotations; +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations; using Amazon.Lambda.Annotations.APIGateway; using Amazon.Lambda.Annotations.SQS; using Amazon.Lambda.SQSEvents; diff --git a/Libraries/test/TestServerlessApp/SQSEventExamples/ValidSQSEvents.cs.txt b/Libraries/test/TestServerlessApp/SQSEventExamples/ValidSQSEvents.cs.txt index 8bd12d68e..5c177b178 100644 --- a/Libraries/test/TestServerlessApp/SQSEventExamples/ValidSQSEvents.cs.txt +++ b/Libraries/test/TestServerlessApp/SQSEventExamples/ValidSQSEvents.cs.txt @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using Amazon.Lambda.Annotations; using Amazon.Lambda.Annotations.SQS; using Amazon.Lambda.SQSEvents; diff --git a/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json b/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json index 03fe9926f..73a489803 100644 --- a/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json +++ b/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json @@ -13,7 +13,7 @@ "template": "serverless.template", "template-parameters": "", "docker-host-build-output-dir": "./bin/Release/lambda-publish", -"s3-bucket" : "test-serverless-app-784dfb1d", -"stack-name" : "test-serverless-app-784dfb1d", +"s3-bucket" : "test-serverless-app-e7994c6b", +"stack-name" : "test-serverless-app-e7994c6b", "function-architecture" : "x86_64" } diff --git a/Libraries/test/TestServerlessApp/serverless.template b/Libraries/test/TestServerlessApp/serverless.template index 4acce8e42..4685ff057 100644 --- a/Libraries/test/TestServerlessApp/serverless.template +++ b/Libraries/test/TestServerlessApp/serverless.template @@ -1496,14 +1496,14 @@ "TestTableStream": { "Type": "DynamoDB", "Properties": { + "StartingPosition": "TRIM_HORIZON", + "BatchSize": 100, "Stream": { "Fn::GetAtt": [ "TestTable", "StreamArn" ] - }, - "StartingPosition": "TRIM_HORIZON", - "BatchSize": 100 + } } } } From 912a23f13b6d93d1879d234d76fddb8f23aa4a7e Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Fri, 17 Apr 2026 11:54:29 -0400 Subject: [PATCH 4/8] update tests --- .agents/skills/new-event-source/SKILL.md | 196 ++++++++++++++++++ .../DeploymentScript.ps1 | 30 +++ .../aws-lambda-tools-defaults.json | 4 +- .../TestServerlessApp/serverless.template | 23 +- 4 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 .agents/skills/new-event-source/SKILL.md diff --git a/.agents/skills/new-event-source/SKILL.md b/.agents/skills/new-event-source/SKILL.md new file mode 100644 index 000000000..12743eb61 --- /dev/null +++ b/.agents/skills/new-event-source/SKILL.md @@ -0,0 +1,196 @@ +--- +name: new-event-source +description: Add a new AWS event source attribute (e.g., Kinesis, Kafka, MQ) to the Lambda .NET Annotations framework, including the attribute class, source generator integration, CloudFormation writer, unit tests, writer tests, source generator tests, and integration tests +--- + +# Adding a New Event Source to Lambda Annotations + +This skill guides you through adding a complete new event source attribute to the AWS Lambda .NET Annotations framework. Use this when a user asks to add support for a new AWS event source like Kinesis, Kafka, MQ, etc. + +## Prerequisites + +Before starting, gather from the user: +1. **Service name** (e.g., "Kinesis", "Kafka", "MQ") +2. **Primary resource identifier** (e.g., stream ARN, topic ARN, broker ARN) +3. **CloudFormation event type string** (e.g., "Kinesis", "MSK", "MQ") +4. **Event class name** from the corresponding `Amazon.Lambda.*Events` NuGet package (e.g., `KinesisEvent`) +5. **Optional properties** the attribute should support (e.g., BatchSize, StartingPosition, Filters) +6. **Whether `@` references use `Fn::GetAtt` or `Ref`** — event source mappings use `Fn::GetAtt`, subscriptions use `Ref` + +## Reference Examples + +Read these files to understand existing patterns before creating new ones: +- **SNS (simplest, subscription-based)**: `Libraries/src/Amazon.Lambda.Annotations/SNS/SNSEventAttribute.cs` +- **SQS (event source mapping with batching)**: `Libraries/src/Amazon.Lambda.Annotations/SQS/SQSEventAttribute.cs` +- **DynamoDB (stream-based)**: `Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs` +- **S3 (notification-based)**: `Libraries/src/Amazon.Lambda.Annotations/S3/S3EventAttribute.cs` + +Also see `Libraries/src/Amazon.Lambda.Annotations/ADDING_NEW_EVENT_SOURCE.md` for the full detailed developer guide. + +## Steps + +### Step 1: Create the Event Attribute Class + +**Create**: `Libraries/src/Amazon.Lambda.Annotations/{ServiceName}/{ServiceName}EventAttribute.cs` + +Key patterns: +- Add copyright header: `// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.` + `// SPDX-License-Identifier: Apache-2.0` +- Inherit from `Attribute` with `[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]` +- Constructor takes the primary resource identifier as a required `string` parameter +- All optional properties use nullable backing fields with `IsSet` internal properties +- Include auto-derived `ResourceName` property (strips `@` prefix or extracts name from ARN) +- Include `internal List Validate()` method with all validation rules +- Use `Regex("^[a-zA-Z0-9]+$")` for ResourceName validation + +### Step 2: Register Type Full Names + +**Modify**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs` + +Add constants: +```csharp +public const string {ServiceName}EventAttribute = "Amazon.Lambda.Annotations.{ServiceName}.{ServiceName}EventAttribute"; +public const string {ServiceName}Event = "Amazon.Lambda.{ServiceName}Events.{ServiceName}Event"; +``` + +Also add to `EventType` enum if needed in `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs`. + +### Step 3: Create the Attribute Builder + +**Create**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/{ServiceName}EventAttributeBuilder.cs` + +Extracts attribute data from Roslyn `AttributeData`. Use consistent `else if` chaining. Reference: `SNSEventAttributeBuilder.cs`. + +### Step 4: Register in AttributeModelBuilder + +**Modify**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs` + +Add `else if` block for the new attribute type after the existing event attribute blocks. + +### Step 5: Register in EventTypeBuilder + +**Modify**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs` + +Add `else if` block mapping the attribute to the `EventType` enum value. + +### Step 6: Add DiagnosticDescriptor + +**Modify**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs` + +Add descriptor with the next available `AWSLambda0XXX` ID for invalid attribute validation errors. + +**Modify**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md` — add the new diagnostic ID. + +### Step 7: Add Validation in LambdaFunctionValidator + +**Modify**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs` + +1. Add `Validate{ServiceName}Events()` call in `ValidateFunction` method +2. Create private `Validate{ServiceName}Events()` method that validates: + - Attribute properties via `Validate()` method + - Method parameters (first must be event type, optional second is `ILambdaContext`) + - Return type (usually `void` or `Task`) + +### Step 8: Add Dependency Check + +**Modify**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs` + +In `ValidateDependencies`, add check for `Amazon.Lambda.{ServiceName}Events` NuGet package. + +### Step 9: Check SyntaxReceiver + +**Check**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs` + +Add the new attribute name if the SyntaxReceiver filters by attribute name strings. + +### Step 10: Add CloudFormation Writer Logic + +**Modify**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs` + +1. Add `case AttributeModel<{ServiceName}EventAttribute>` in the event processing switch +2. Create `Process{ServiceName}Attribute()` method that writes CF template properties + - Event source mappings (SQS, DynamoDB, Kinesis): use `Fn::GetAtt` for `@` references + - Subscription events (SNS): use `Ref` for `@` references + - Track synced properties in metadata + +### Step 11: Create Attribute Unit Tests + +**Create**: `Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/{ServiceName}EventAttributeTests.cs` + +Cover: constructor, defaults, property tracking, ResourceName derivation, all validation paths. Reference: `SQSEventAttributeTests.cs`, `DynamoDBEventAttributeTests.cs`, `SNSEventAttributeTests.cs`. + +### Step 12: Create CloudFormation Writer Tests + +**Create**: `Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/{ServiceName}EventsTests.cs` + +This is a `partial class CloudFormationWriterTests`. Include tests for: +1. `Verify{ServiceName}EventAttributes_AreCorrectlyApplied` — Theory with JSON/YAML and property combinations +2. `Verify{ServiceName}EventProperties_AreSyncedCorrectly` — Synced properties update when attributes change +3. `SwitchBetweenArnAndRef_For{Resource}` — ARN to `@` reference switching +4. `Verify{Resource}CanBeSet_FromCloudFormationParameter` — CF Parameters handling +5. `VerifyManuallySet{ServiceName}EventProperties_ArePreserved` — Hand-edited template preservation + +Reference: `SQSEventsTests.cs`, `DynamoDBEventsTests.cs`, `SNSEventsTests.cs`. + +### Step 13: Create Valid Event Examples + Source Generator Test + +**Create**: `Libraries/test/TestServerlessApp/{ServiceName}EventExamples/Valid{ServiceName}Events.cs.txt` +**Create**: `Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/{ServiceName}/` (generated handler snapshots) +**Create**: `Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/{serviceName}Events.template` +**Modify**: `Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs` — add `VerifyValid{ServiceName}Events()` test + +### Step 14: Create Invalid Event Examples + Source Generator Test + +**Create**: `Libraries/test/TestServerlessApp/{ServiceName}EventExamples/Invalid{ServiceName}Events.cs.error` + +Cover: invalid property values, invalid params, invalid return type, multiple events, invalid ARN, invalid resource name. + +**Modify**: `Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs` — add `VerifyInvalid{ServiceName}Events_ThrowsCompilationErrors()` test with diagnostic assertions including line spans. + +### Step 15: Create Generated Code Snapshots + +**Create**: `Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/{ServiceName}/` + +Tip: Run the source generator once to get actual output, then use as snapshot. + +### Step 16: Create Integration Test + +**Create**: `Libraries/test/TestServerlessApp.IntegrationTests/{ServiceName}EventSourceMapping.cs` +**Modify**: `Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs` — resource lookup +**Modify**: `Libraries/test/TestServerlessApp.IntegrationTests/DeploymentScript.ps1` — if needed + +### Step 17: Update AnalyzerReleases.Unshipped.md + +**Modify**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md` + +## File Map Summary + +| Action | File Path | +|--------|-----------| +| Create | `src/Amazon.Lambda.Annotations/{ServiceName}/{ServiceName}EventAttribute.cs` | +| Modify | `src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs` | +| Create | `src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/{ServiceName}EventAttributeBuilder.cs` | +| Modify | `src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs` | +| Modify | `src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs` | +| Modify | `src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs` | +| Modify | `src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs` | +| Modify | `src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs` | +| Modify | `src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs` | +| Modify | `src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md` | +| Create | `test/Amazon.Lambda.Annotations.SourceGenerators.Tests/{ServiceName}EventAttributeTests.cs` | +| Create | `test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/{ServiceName}EventsTests.cs` | +| Create | `test/TestServerlessApp/{ServiceName}EventExamples/Valid{ServiceName}Events.cs.txt` | +| Create | `test/TestServerlessApp/{ServiceName}EventExamples/Invalid{ServiceName}Events.cs.error` | +| Create | `test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/{ServiceName}/` | +| Create | `test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/{serviceName}Events.template` | +| Modify | `test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs` | +| Create | `test/TestServerlessApp.IntegrationTests/{ServiceName}EventSourceMapping.cs` | +| Modify | `test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs` | + +## Important Conventions + +- **Copyright header** on every new `.cs` file +- **Consistent `else if` chaining** in attribute builders (never `if` then `if` for the same loop) +- **Both JSON and YAML** template formats must be tested in writer tests +- **Invalid event test spans** must reference exact line numbers in the `.cs.error` file +- **`.cs.txt` extension** for valid test files (prevents deployment) +- **`.cs.error` extension** for invalid test files (prevents compilation) diff --git a/Libraries/test/TestServerlessApp.IntegrationTests/DeploymentScript.ps1 b/Libraries/test/TestServerlessApp.IntegrationTests/DeploymentScript.ps1 index c8a2147d1..bbff35b47 100644 --- a/Libraries/test/TestServerlessApp.IntegrationTests/DeploymentScript.ps1 +++ b/Libraries/test/TestServerlessApp.IntegrationTests/DeploymentScript.ps1 @@ -86,6 +86,36 @@ try Write-Host "Added TestTopic resource to serverless.template" } + # Add TestTable resource to serverless.template for DynamoDB event integration testing + # The source generator creates a Fn::GetAtt reference to TestTable for StreamArn but doesn't define the resource itself + $template = Get-Content $templatePath | Out-String | ConvertFrom-Json + if (-not $template.Resources.PSObject.Properties['TestTable']) { + $testTableResource = @{ + Type = "AWS::DynamoDB::Table" + Properties = @{ + BillingMode = "PAY_PER_REQUEST" + AttributeDefinitions = @( + @{ + AttributeName = "Id" + AttributeType = "S" + } + ) + KeySchema = @( + @{ + AttributeName = "Id" + KeyType = "HASH" + } + ) + StreamSpecification = @{ + StreamViewType = "NEW_AND_OLD_IMAGES" + } + } + } + $template.Resources | Add-Member -NotePropertyName "TestTable" -NotePropertyValue $testTableResource -Force + $template | ConvertTo-Json -Depth 100 | Set-Content $templatePath + Write-Host "Added TestTable resource to serverless.template" + } + dotnet restore Write-Host "Creating CloudFormation Stack $identifier, Architecture $arch, Runtime $runtime" dotnet lambda deploy-serverless --template-parameters "ArchitectureTypeParameter=$arch" diff --git a/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json b/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json index 73a489803..5c0fd4566 100644 --- a/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json +++ b/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json @@ -13,7 +13,7 @@ "template": "serverless.template", "template-parameters": "", "docker-host-build-output-dir": "./bin/Release/lambda-publish", -"s3-bucket" : "test-serverless-app-e7994c6b", -"stack-name" : "test-serverless-app-e7994c6b", +"s3-bucket" : "test-serverless-app-a14f34c1", +"stack-name" : "test-serverless-app-a14f34c1", "function-architecture" : "x86_64" } diff --git a/Libraries/test/TestServerlessApp/serverless.template b/Libraries/test/TestServerlessApp/serverless.template index 4685ff057..7d518334e 100644 --- a/Libraries/test/TestServerlessApp/serverless.template +++ b/Libraries/test/TestServerlessApp/serverless.template @@ -1508,6 +1508,27 @@ } } } + }, + "TestTable": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "AttributeDefinitions": [ + { + "AttributeName": "Id", + "AttributeType": "S" + } + ], + "KeySchema": [ + { + "KeyType": "HASH", + "AttributeName": "Id" + } + ], + "BillingMode": "PAY_PER_REQUEST", + "StreamSpecification": { + "StreamViewType": "NEW_AND_OLD_IMAGES" + } + } } } -} \ No newline at end of file +} From 1032bfe39557e1b06a404bcc97a71778520358f9 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Fri, 17 Apr 2026 11:55:01 -0400 Subject: [PATCH 5/8] update skill --- .agents/skills/new-event-source/SKILL.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/.agents/skills/new-event-source/SKILL.md b/.agents/skills/new-event-source/SKILL.md index 12743eb61..3adfdc5f1 100644 --- a/.agents/skills/new-event-source/SKILL.md +++ b/.agents/skills/new-event-source/SKILL.md @@ -25,8 +25,6 @@ Read these files to understand existing patterns before creating new ones: - **DynamoDB (stream-based)**: `Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs` - **S3 (notification-based)**: `Libraries/src/Amazon.Lambda.Annotations/S3/S3EventAttribute.cs` -Also see `Libraries/src/Amazon.Lambda.Annotations/ADDING_NEW_EVENT_SOURCE.md` for the full detailed developer guide. - ## Steps ### Step 1: Create the Event Attribute Class From fd2ccfec72da32d71f16e12ef99e7eba20b1033e Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Fri, 17 Apr 2026 12:12:02 -0400 Subject: [PATCH 6/8] pr comments --- .../DynamoDB/DynamoDBEventAttribute.cs | 4 +++ .../DynamoDBEventAttributeTests.cs | 26 +++++++++++++++++-- .../aws-lambda-tools-defaults.json | 8 +++--- .../TestServerlessApp/serverless.template | 2 +- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs index 1c5b8d6f5..d1801d483 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs +++ b/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs @@ -140,6 +140,10 @@ internal List Validate() { validationErrors.Add($"{nameof(DynamoDBEventAttribute.Stream)} must not be null or empty"); } + else if (Stream.StartsWith("@") && string.IsNullOrWhiteSpace(Stream.Substring(1))) + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.Stream)} = {Stream}. The '@' prefix must be followed by a non-empty resource or parameter name"); + } else if (!Stream.StartsWith("@")) { if (!Stream.Contains(":dynamodb:") || !Stream.Contains("/stream/")) diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DynamoDBEventAttributeTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DynamoDBEventAttributeTests.cs index 72663632c..9a86da4c4 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DynamoDBEventAttributeTests.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DynamoDBEventAttributeTests.cs @@ -47,7 +47,7 @@ public void ResourceName_DerivedFromStream_WithAtPrefix() [Fact] public void ResourceName_DerivedFromStreamArn() { - var attr = new DynamoDBEventAttribute("arn:aws:dynamodb:us-east-1:123456789:table/MyTable/stream/2024-01-01T00:00:00.000"); + var attr = new DynamoDBEventAttribute("arn:aws:dynamodb:us-east-1:123456789012:table/MyTable/stream/2024-01-01T00:00:00.000"); Assert.False(attr.IsResourceNameSet); Assert.Equal("MyTable", attr.ResourceName); @@ -201,7 +201,7 @@ public void Validate_ValidResourceReference_ReturnsNoErrors() [Fact] public void Validate_ValidStreamArn_ReturnsNoErrors() { - var attr = new DynamoDBEventAttribute("arn:aws:dynamodb:us-east-1:123456789:table/MyTable/stream/2024-01-01T00:00:00.000"); + var attr = new DynamoDBEventAttribute("arn:aws:dynamodb:us-east-1:123456789012:table/MyTable/stream/2024-01-01T00:00:00.000"); var errors = attr.Validate(); Assert.Empty(errors); @@ -270,6 +270,28 @@ public void Validate_InvalidStartingPosition_ReturnsError() Assert.Contains("StartingPosition", errors[0]); } + [Fact] + public void Validate_AtSignOnly_ReturnsError() + { + var attr = new DynamoDBEventAttribute("@"); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("Stream", errors[0]); + Assert.Contains("'@' prefix must be followed by a non-empty resource or parameter name", errors[0]); + } + + [Fact] + public void Validate_AtSignWithWhitespace_ReturnsError() + { + var attr = new DynamoDBEventAttribute("@ "); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("Stream", errors[0]); + Assert.Contains("'@' prefix must be followed by a non-empty resource or parameter name", errors[0]); + } + [Fact] public void Validate_MultipleErrors_ReturnsAll() { diff --git a/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json b/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json index 5c0fd4566..0b96350ff 100644 --- a/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json +++ b/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json @@ -13,7 +13,7 @@ "template": "serverless.template", "template-parameters": "", "docker-host-build-output-dir": "./bin/Release/lambda-publish", -"s3-bucket" : "test-serverless-app-a14f34c1", -"stack-name" : "test-serverless-app-a14f34c1", -"function-architecture" : "x86_64" -} + "s3-bucket": "test-serverless-app", + "stack-name": "test-serverless-app", + "function-architecture": "x86_64" +} \ No newline at end of file diff --git a/Libraries/test/TestServerlessApp/serverless.template b/Libraries/test/TestServerlessApp/serverless.template index 7d518334e..fae491e28 100644 --- a/Libraries/test/TestServerlessApp/serverless.template +++ b/Libraries/test/TestServerlessApp/serverless.template @@ -1531,4 +1531,4 @@ } } } -} +} \ No newline at end of file From c1170f7015f304510bda4957e7afddc348bd2e66 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Fri, 17 Apr 2026 12:35:45 -0400 Subject: [PATCH 7/8] revert runtime support change --- .../Amazon.Lambda.RuntimeSupport.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Amazon.Lambda.RuntimeSupport.csproj b/Libraries/src/Amazon.Lambda.RuntimeSupport/Amazon.Lambda.RuntimeSupport.csproj index 0929eb357..6f8dabfa2 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Amazon.Lambda.RuntimeSupport.csproj +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Amazon.Lambda.RuntimeSupport.csproj @@ -3,7 +3,7 @@ - netstandard2.0;net6.0;net8.0;net9.0;net10.0 + netstandard2.0;net6.0;net8.0;net9.0;net10.0;net11.0 1.14.3 Provides a bootstrap and Lambda Runtime API Client to help you to develop custom .NET Core Lambda Runtimes. Amazon.Lambda.RuntimeSupport From 863633ce42aa4643267ec80a996b35d25dd4190b Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Mon, 20 Apr 2026 15:31:51 -0400 Subject: [PATCH 8/8] use enum --- .agents/skills/new-event-source/SKILL.md | 1 + .../DynamoDBEventAttributeBuilder.cs | 4 +-- .../Writers/CloudFormationWriter.cs | 2 +- .../DynamoDB/DynamoDBEventAttribute.cs | 10 +------ .../DynamoDB/StartingPosition.cs | 22 +++++++++++++++ .../DynamoDBEventAttributeTests.cs | 27 +++++-------------- .../SourceGeneratorTests.cs | 7 ++--- .../WriterTests/DynamoDBEventsTests.cs | 4 +-- .../InvalidDynamoDBEvents.cs.error | 2 +- .../ValidDynamoDBEvents.cs.txt | 2 +- .../DynamoDbStreamProcessing.cs | 2 +- 11 files changed, 41 insertions(+), 42 deletions(-) create mode 100644 Libraries/src/Amazon.Lambda.Annotations/DynamoDB/StartingPosition.cs diff --git a/.agents/skills/new-event-source/SKILL.md b/.agents/skills/new-event-source/SKILL.md index 3adfdc5f1..a6aad04d9 100644 --- a/.agents/skills/new-event-source/SKILL.md +++ b/.agents/skills/new-event-source/SKILL.md @@ -192,3 +192,4 @@ Tip: Run the source generator once to get actual output, then use as snapshot. - **Invalid event test spans** must reference exact line numbers in the `.cs.error` file - **`.cs.txt` extension** for valid test files (prevents deployment) - **`.cs.error` extension** for invalid test files (prevents compilation) +- **Use enums instead of strings** when you need to represent a fixed, known set of constants that do not change frequently (e.g., `StartingPosition`, `AuthType`, `HttpApiVersion`). Enums provide compile-time type safety, eliminate the need for manual string validation in the `Validate()` method, and prevent invalid values from being set. In attribute builders, enum values come through Roslyn's `AttributeData` as their underlying `int` value and must be cast accordingly (e.g., `(MyEnum)(int)pair.Value.Value`). When writing enum values to CloudFormation templates, use `.ToString()` to convert back to the string representation. diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DynamoDBEventAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DynamoDBEventAttributeBuilder.cs index ce0d39c6c..76c45c3c4 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DynamoDBEventAttributeBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DynamoDBEventAttributeBuilder.cs @@ -31,9 +31,9 @@ public static DynamoDBEventAttribute Build(AttributeData att) { data.BatchSize = batchSize; } - else if (pair.Key == nameof(data.StartingPosition) && pair.Value.Value is string startingPosition) + else if (pair.Key == nameof(data.StartingPosition) && pair.Value.Value is int startingPosition) { - data.StartingPosition = startingPosition; + data.StartingPosition = (StartingPosition)startingPosition; } else if (pair.Key == nameof(data.Enabled) && pair.Value.Value is bool enabled) { diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs index 92bf23053..159b898f3 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs @@ -709,7 +709,7 @@ private string ProcessDynamoDBAttribute(ILambdaFunctionSerializable lambdaFuncti } // StartingPosition - SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "StartingPosition", att.StartingPosition); + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "StartingPosition", att.StartingPosition.ToString()); // BatchSize if (att.IsBatchSizeSet) diff --git a/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs index d1801d483..f407ba609 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs +++ b/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs @@ -76,7 +76,7 @@ public uint BatchSize /// 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"; + public StartingPosition StartingPosition { get; set; } = StartingPosition.LATEST; internal bool IsStartingPositionSet => true; /// @@ -128,14 +128,6 @@ internal List Validate() { validationErrors.Add($"{nameof(DynamoDBEventAttribute.MaximumBatchingWindowInSeconds)} = {MaximumBatchingWindowInSeconds}. It must be between 0 and 300"); } - if (string.IsNullOrEmpty(StartingPosition)) - { - validationErrors.Add($"{nameof(DynamoDBEventAttribute.StartingPosition)} must not be null or empty. It must be either TRIM_HORIZON or LATEST"); - } - else if (StartingPosition != "TRIM_HORIZON" && StartingPosition != "LATEST") - { - validationErrors.Add($"{nameof(DynamoDBEventAttribute.StartingPosition)} = {StartingPosition}. It must be either TRIM_HORIZON or LATEST"); - } if (string.IsNullOrWhiteSpace(Stream)) { validationErrors.Add($"{nameof(DynamoDBEventAttribute.Stream)} must not be null or empty"); diff --git a/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/StartingPosition.cs b/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/StartingPosition.cs new file mode 100644 index 000000000..5b1cc881a --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/StartingPosition.cs @@ -0,0 +1,22 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace Amazon.Lambda.Annotations.DynamoDB +{ + /// + /// The position in the DynamoDB stream where Lambda starts reading. + /// + public enum StartingPosition + { + /// + /// Start reading at the most recent record in the shard. + /// + LATEST, + + /// + /// Start reading at the last untrimmed record in the shard. + /// This is the oldest record in the shard. + /// + TRIM_HORIZON + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DynamoDBEventAttributeTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DynamoDBEventAttributeTests.cs index 9a86da4c4..4f2a0fe91 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DynamoDBEventAttributeTests.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DynamoDBEventAttributeTests.cs @@ -23,7 +23,7 @@ public void DefaultValues_AreCorrect() { var attr = new DynamoDBEventAttribute("@MyTable"); - Assert.Equal("LATEST", attr.StartingPosition); + Assert.Equal(StartingPosition.LATEST, attr.StartingPosition); Assert.False(attr.IsBatchSizeSet); Assert.False(attr.IsEnabledSet); Assert.False(attr.IsMaximumBatchingWindowInSecondsSet); @@ -94,7 +94,7 @@ public void StartingPosition_DefaultValue_IsLatest() { var attr = new DynamoDBEventAttribute("@MyTable"); - Assert.Equal("LATEST", attr.StartingPosition); + Assert.Equal(StartingPosition.LATEST, attr.StartingPosition); } [Fact] @@ -102,10 +102,10 @@ public void StartingPosition_CanBeSetToTrimHorizon() { var attr = new DynamoDBEventAttribute("@MyTable") { - StartingPosition = "TRIM_HORIZON" + StartingPosition = StartingPosition.TRIM_HORIZON }; - Assert.Equal("TRIM_HORIZON", attr.StartingPosition); + Assert.Equal(StartingPosition.TRIM_HORIZON, attr.StartingPosition); } // ===== MaximumBatchingWindowInSeconds Tests ===== @@ -257,18 +257,6 @@ public void Validate_MaxBatchingWindowTooLarge_ReturnsError() Assert.Contains("MaximumBatchingWindowInSeconds", errors[0]); } - [Fact] - public void Validate_InvalidStartingPosition_ReturnsError() - { - var attr = new DynamoDBEventAttribute("@MyTable") - { - StartingPosition = "INVALID" - }; - - var errors = attr.Validate(); - Assert.Single(errors); - Assert.Contains("StartingPosition", errors[0]); - } [Fact] public void Validate_AtSignOnly_ReturnsError() @@ -298,12 +286,11 @@ public void Validate_MultipleErrors_ReturnsAll() var attr = new DynamoDBEventAttribute("not-valid") { ResourceName = "invalid!", - BatchSize = 10001, - StartingPosition = "INVALID" + BatchSize = 10001 }; var errors = attr.Validate(); - Assert.Equal(4, errors.Count); + Assert.Equal(3, errors.Count); } [Fact] @@ -313,7 +300,7 @@ public void Validate_AllValidWithOptionals_ReturnsNoErrors() { ResourceName = "MyDynamoDBEvent", BatchSize = 100, - StartingPosition = "TRIM_HORIZON", + StartingPosition = StartingPosition.TRIM_HORIZON, MaximumBatchingWindowInSeconds = 60, Filters = "{\"eventName\": [\"INSERT\"]}", Enabled = true diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs index 1c8c18171..a377eb5a7 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs @@ -1404,6 +1404,7 @@ public async Task VerifyInvalidDynamoDBEvents_ThrowsCompilationErrors() (Path.Combine("TestServerlessApp", "DynamoDBEventExamples", "InvalidDynamoDBEvents.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "DynamoDBEventExamples", "InvalidDynamoDBEvents.cs.error"))), (Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"))), (Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "DynamoDBEventAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "DynamoDBEventAttribute.cs"))), + (Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "StartingPosition.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "StartingPosition.cs"))), (Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"))), }, ExpectedDiagnostics = @@ -1418,11 +1419,6 @@ public async Task VerifyInvalidDynamoDBEvents_ThrowsCompilationErrors() .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 17, 9, 22, 10) .WithArguments("MaximumBatchingWindowInSeconds = 301. It must be between 0 and 300"), - // ProcessMessageWithInvalidDynamoDBEventAttributes: StartingPosition = INVALID - DiagnosticResult.CompilerError("AWSLambda0137") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 17, 9, 22, 10) - .WithArguments("StartingPosition = INVALID. It must be either TRIM_HORIZON or LATEST"), - // ProcessMessageWithInvalidParameters: too many parameters DiagnosticResult.CompilerError("AWSLambda0117") .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 24, 9, 29, 10) @@ -1477,6 +1473,7 @@ public async Task VerifyValidDynamoDBEvents() (Path.Combine("TestServerlessApp", "DynamoDBEventExamples", "ValidDynamoDBEvents.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "DynamoDBEventExamples", "ValidDynamoDBEvents.cs.txt"))), (Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"))), (Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "DynamoDBEventAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "DynamoDBEventAttribute.cs"))), + (Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "StartingPosition.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "StartingPosition.cs"))), (Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"))), }, GeneratedSources = diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/DynamoDBEventsTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/DynamoDBEventsTests.cs index c9d21fb8a..065d04d95 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/DynamoDBEventsTests.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/DynamoDBEventsTests.cs @@ -58,7 +58,7 @@ public void VerifyDynamoDBEventAttributes_AreCorrectlyApplied(CloudFormationTemp Assert.Equal([att.Stream.Substring(1), "StreamArn"], templateWriter.GetToken>($"{eventPropertiesPath}.Stream.Fn::GetAtt")); } - Assert.Equal(att.StartingPosition, templateWriter.GetToken($"{eventPropertiesPath}.StartingPosition")); + Assert.Equal(att.StartingPosition.ToString(), templateWriter.GetToken($"{eventPropertiesPath}.StartingPosition")); Assert.Equal(att.IsBatchSizeSet, templateWriter.Exists($"{eventPropertiesPath}.BatchSize")); if (att.IsBatchSizeSet) @@ -348,7 +348,7 @@ public DynamoDBEventsTestData() Filters = "SOME-FILTER1; SOME-FILTER2", MaximumBatchingWindowInSeconds = 15, Enabled = false, - StartingPosition = "TRIM_HORIZON" + StartingPosition = StartingPosition.TRIM_HORIZON }]); } } diff --git a/Libraries/test/TestServerlessApp/DynamoDBEventExamples/InvalidDynamoDBEvents.cs.error b/Libraries/test/TestServerlessApp/DynamoDBEventExamples/InvalidDynamoDBEvents.cs.error index 243ddfb7a..dda434652 100644 --- a/Libraries/test/TestServerlessApp/DynamoDBEventExamples/InvalidDynamoDBEvents.cs.error +++ b/Libraries/test/TestServerlessApp/DynamoDBEventExamples/InvalidDynamoDBEvents.cs.error @@ -15,7 +15,7 @@ namespace TestServerlessApp.DynamoDBEventExamples public class InvalidDynamoDBEvents { [LambdaFunction(PackageType = LambdaPackageType.Image)] - [DynamoDBEvent("@testTable", BatchSize = 10001, MaximumBatchingWindowInSeconds = 301, StartingPosition = "INVALID")] + [DynamoDBEvent("@testTable", BatchSize = 10001, MaximumBatchingWindowInSeconds = 301)] public void ProcessMessageWithInvalidDynamoDBEventAttributes(DynamoDBEvent evnt) { Console.WriteLine($"Event processed: {evnt}"); diff --git a/Libraries/test/TestServerlessApp/DynamoDBEventExamples/ValidDynamoDBEvents.cs.txt b/Libraries/test/TestServerlessApp/DynamoDBEventExamples/ValidDynamoDBEvents.cs.txt index c10996d5d..90bec559f 100644 --- a/Libraries/test/TestServerlessApp/DynamoDBEventExamples/ValidDynamoDBEvents.cs.txt +++ b/Libraries/test/TestServerlessApp/DynamoDBEventExamples/ValidDynamoDBEvents.cs.txt @@ -16,7 +16,7 @@ namespace TestServerlessApp.DynamoDBEventExamples { [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("arn:aws:dynamodb:us-east-2:444455556666:table/MyTable2/stream/2024-01-01T00:00:00", StartingPosition = StartingPosition.TRIM_HORIZON, Enabled = false)] [DynamoDBEvent("@testTable", ResourceName = "testTableEvent")] public void ProcessMessages(DynamoDBEvent evnt) { diff --git a/Libraries/test/TestServerlessApp/DynamoDbStreamProcessing.cs b/Libraries/test/TestServerlessApp/DynamoDbStreamProcessing.cs index 29eacb3a2..cd865c0f6 100644 --- a/Libraries/test/TestServerlessApp/DynamoDbStreamProcessing.cs +++ b/Libraries/test/TestServerlessApp/DynamoDbStreamProcessing.cs @@ -8,7 +8,7 @@ namespace TestServerlessApp public class DynamoDbStreamProcessing { [LambdaFunction(ResourceName = "DynamoDBStreamHandler", Policies = "AWSLambdaDynamoDBExecutionRole", PackageType = LambdaPackageType.Image)] - [DynamoDBEvent("@TestTable", ResourceName = "TestTableStream", BatchSize = 100, StartingPosition = "TRIM_HORIZON")] + [DynamoDBEvent("@TestTable", ResourceName = "TestTableStream", BatchSize = 100, StartingPosition = StartingPosition.TRIM_HORIZON)] public void HandleStream(DynamoDBEvent evnt, ILambdaContext lambdaContext) { lambdaContext.Logger.Log($"Received {evnt.Records.Count} records");