diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs index 69c4f9428..728f649fe 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs @@ -242,5 +242,12 @@ public static class DiagnosticDescriptors category: "AWSLambdaCSharpGenerator", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor InvalidDynamoDBEventAttribute = new DiagnosticDescriptor(id: "AWSLambda0132", + title: "Invalid DynamoDBEventAttribute", + messageFormat: "Invalid DynamoDBEventAttribute encountered: {0}", + category: "AWSLambdaCSharpGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true); } } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs index 328a29ac5..f9cd13657 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs @@ -1,5 +1,6 @@ using System; using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.Annotations.DynamoDB; using Amazon.Lambda.Annotations.SQS; using Microsoft.CodeAnalysis; @@ -90,6 +91,15 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext Type = TypeModelBuilder.Build(att.AttributeClass, context) }; } + else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.DynamoDBEventAttribute), SymbolEqualityComparer.Default)) + { + var data = DynamoDBEventAttributeBuilder.Build(att); + model = new AttributeModel + { + Data = data, + Type = TypeModelBuilder.Build(att.AttributeClass, context) + }; + } else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.HttpApiAuthorizerAttribute), SymbolEqualityComparer.Default)) { var data = HttpApiAuthorizerAttributeBuilder.Build(att); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DynamoDBEventAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DynamoDBEventAttributeBuilder.cs new file mode 100644 index 000000000..ae3affa94 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DynamoDBEventAttributeBuilder.cs @@ -0,0 +1,52 @@ +using Amazon.Lambda.Annotations.DynamoDB; +using Microsoft.CodeAnalysis; +using System; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes +{ + /// + /// Builder for . + /// + public class DynamoDBEventAttributeBuilder + { + public static DynamoDBEventAttribute Build(AttributeData att) + { + if (att.ConstructorArguments.Length != 1) + { + throw new NotSupportedException($"{TypeFullNames.DynamoDBEventAttribute} must have constructor with 1 argument."); + } + var stream = att.ConstructorArguments[0].Value as string; + var data = new DynamoDBEventAttribute(stream); + + foreach (var pair in att.NamedArguments) + { + if (pair.Key == nameof(data.ResourceName) && pair.Value.Value is string resourceName) + { + data.ResourceName = resourceName; + } + if (pair.Key == nameof(data.BatchSize) && pair.Value.Value is uint batchSize) + { + data.BatchSize = batchSize; + } + else if (pair.Key == nameof(data.StartingPosition) && pair.Value.Value is string startingPosition) + { + data.StartingPosition = startingPosition; + } + else if (pair.Key == nameof(data.Enabled) && pair.Value.Value is bool enabled) + { + data.Enabled = enabled; + } + else if (pair.Key == nameof(data.MaximumBatchingWindowInSeconds) && pair.Value.Value is uint maximumBatchingWindowInSeconds) + { + data.MaximumBatchingWindowInSeconds = maximumBatchingWindowInSeconds; + } + else if (pair.Key == nameof(data.Filters) && pair.Value.Value is string filters) + { + data.Filters = filters; + } + } + + return data; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs index 3f5775851..205a12fd0 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs @@ -26,6 +26,10 @@ public static HashSet Build(IMethodSymbol lambdaMethodSymbol, { events.Add(EventType.SQS); } + else if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.DynamoDBEventAttribute) + { + events.Add(EventType.DynamoDB); + } else if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.HttpApiAuthorizerAttribute || attribute.AttributeClass.ToDisplayString() == TypeFullNames.RestApiAuthorizerAttribute) { diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs index a5d7ce9ab..3d831cecb 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs @@ -21,7 +21,8 @@ internal class SyntaxReceiver : ISyntaxContextReceiver { "RestApiAuthorizerAttribute", "RestApiAuthorizer" }, { "HttpApiAttribute", "HttpApi" }, { "RestApiAttribute", "RestApi" }, - { "SQSEventAttribute", "SQSEvent" } + { "SQSEventAttribute", "SQSEvent" }, + { "DynamoDBEventAttribute", "DynamoDBEvent" } }; public List LambdaMethods { get; } = new List(); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs index 6e15c2175..088e5a4f4 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs @@ -46,6 +46,9 @@ public static class TypeFullNames public const string SQSBatchResponse = "Amazon.Lambda.SQSEvents.SQSBatchResponse"; public const string SQSEventAttribute = "Amazon.Lambda.Annotations.SQS.SQSEventAttribute"; + public const string DynamoDBEvent = "Amazon.Lambda.DynamoDBEvents.DynamoDBEvent"; + public const string DynamoDBEventAttribute = "Amazon.Lambda.Annotations.DynamoDB.DynamoDBEventAttribute"; + public const string LambdaSerializerAttribute = "Amazon.Lambda.Core.LambdaSerializerAttribute"; public const string DefaultLambdaSerializer = "Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer"; @@ -67,7 +70,8 @@ public static class TypeFullNames { RestApiAttribute, HttpApiAttribute, - SQSEventAttribute + SQSEventAttribute, + DynamoDBEventAttribute }; } } \ No newline at end of file diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs index 733124209..5c712fd58 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs @@ -2,6 +2,7 @@ using Amazon.Lambda.Annotations.SourceGenerator.Extensions; using Amazon.Lambda.Annotations.SourceGenerator.Models; using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; +using Amazon.Lambda.Annotations.DynamoDB; using Amazon.Lambda.Annotations.SQS; using Microsoft.CodeAnalysis; using System.Collections.Generic; @@ -59,6 +60,7 @@ internal static bool ValidateFunction(GeneratorExecutionContext context, IMethod // Validate Events ValidateApiGatewayEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateSqsEvents(lambdaFunctionModel, methodLocation, diagnostics); + ValidateDynamoDBEvents(lambdaFunctionModel, methodLocation, diagnostics); return ReportDiagnostics(diagnosticReporter, diagnostics); } @@ -86,6 +88,16 @@ internal static bool ValidateDependencies(GeneratorExecutionContext context, IMe } } + // Check for references to "Amazon.Lambda.DynamoDBEvents" if the Lambda method is annotated with DynamoDBEvent attribute. + if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.DynamoDBEventAttribute)) + { + if (context.Compilation.ReferencedAssemblyNames.FirstOrDefault(x => x.Name == "Amazon.Lambda.DynamoDBEvents") == null) + { + diagnosticReporter.Report(Diagnostic.Create(DiagnosticDescriptors.MissingDependencies, methodLocation, "Amazon.Lambda.DynamoDBEvents")); + return false; + } + } + return true; } @@ -268,6 +280,45 @@ private static void ValidateSqsEvents(LambdaFunctionModel lambdaFunctionModel, L } } + private static void ValidateDynamoDBEvents(LambdaFunctionModel lambdaFunctionModel, Location methodLocation, List diagnostics) + { + if (!lambdaFunctionModel.LambdaMethod.Events.Contains(EventType.DynamoDB)) + { + return; + } + + // Validate DynamoDBEventAttributes + foreach (var att in lambdaFunctionModel.Attributes) + { + if (att.Type.FullName != TypeFullNames.DynamoDBEventAttribute) + continue; + + var dynamoDBEventAttribute = ((AttributeModel)att).Data; + var validationErrors = dynamoDBEventAttribute.Validate(); + validationErrors.ForEach(errorMessage => diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidDynamoDBEventAttribute, methodLocation, errorMessage))); + } + + // Validate method parameters - When using DynamoDBEventAttribute, the method signature must be (DynamoDBEvent evnt) or (DynamoDBEvent evnt, ILambdaContext context) + var parameters = lambdaFunctionModel.LambdaMethod.Parameters; + if (parameters.Count == 0 || + parameters.Count > 2 || + (parameters.Count == 1 && parameters[0].Type.FullName != TypeFullNames.DynamoDBEvent) || + (parameters.Count == 2 && (parameters[0].Type.FullName != TypeFullNames.DynamoDBEvent || parameters[1].Type.FullName != TypeFullNames.ILambdaContext))) + { + var errorMessage = $"When using the {nameof(DynamoDBEventAttribute)}, the Lambda method can accept at most 2 parameters. " + + $"The first parameter is required and must be of type {TypeFullNames.DynamoDBEvent}. " + + $"The second parameter is optional and must be of type {TypeFullNames.ILambdaContext}."; + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidLambdaMethodSignature, methodLocation, errorMessage)); + } + + // Validate method return type - When using DynamoDBEventAttribute, the return type must be either void or Task + if (!lambdaFunctionModel.LambdaMethod.ReturnsVoid && !lambdaFunctionModel.LambdaMethod.ReturnsVoidTask) + { + var errorMessage = $"When using the {nameof(DynamoDBEventAttribute)}, the Lambda method can return either void or {TypeFullNames.Task}"; + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidLambdaMethodSignature, methodLocation, errorMessage)); + } + } + private static bool ReportDiagnostics(DiagnosticReporter diagnosticReporter, List diagnostics) { var isValid = true; diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs index a59aaf6d4..b9911b7da 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs @@ -1,4 +1,5 @@ using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.Annotations.DynamoDB; using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics; using Amazon.Lambda.Annotations.SourceGenerator.FileIO; using Amazon.Lambda.Annotations.SourceGenerator.Models; @@ -221,6 +222,10 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la eventName = ProcessSqsAttribute(lambdaFunction, sqsAttributeModel.Data, currentSyncedEventProperties); currentSyncedEvents.Add(eventName); break; + case AttributeModel dynamoDBAttributeModel: + eventName = ProcessDynamoDBAttribute(lambdaFunction, dynamoDBAttributeModel.Data, currentSyncedEventProperties); + currentSyncedEvents.Add(eventName); + break; } } @@ -600,6 +605,68 @@ private string ProcessSqsAttribute(ILambdaFunctionSerializable lambdaFunction, S /// Writes all properties associated with to the serverless template. /// + /// + /// 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 the default values for the Lambda function's metadata and properties. /// diff --git a/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs new file mode 100644 index 000000000..d85c0963c --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Amazon.Lambda.Annotations.DynamoDB +{ + /// + /// This attribute defines the DynamoDB event source configuration for a Lambda function. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + public class DynamoDBEventAttribute : Attribute + { + private static readonly Regex _resourceNameRegex = new Regex("^[a-zA-Z0-9]+$"); + + /// + /// The DynamoDB stream that will act as the event trigger for the Lambda function. + /// This can either be the stream ARN or reference to the DynamoDB table resource that is already defined in the serverless template. + /// To reference a DynamoDB table resource in the serverless template, prefix the resource name with "@" symbol. + /// + public string Stream { get; set; } + + /// + /// The CloudFormation resource name for the DynamoDB event source mapping. + /// + public string ResourceName + { + get + { + if (IsResourceNameSet) + { + return resourceName; + } + if (Stream.StartsWith("@")) + { + return Stream.Substring(1); + } + + // DynamoDB stream ARN format: arn:aws:dynamodb:region:account:table/TableName/stream/timestamp + var arnParts = Stream.Split('/'); + if (arnParts.Length >= 2) + { + var tableName = arnParts[1]; + return string.Join(string.Empty, tableName.Where(char.IsLetterOrDigit)); + } + return string.Join(string.Empty, Stream.Where(char.IsLetterOrDigit)); + } + set => resourceName = value; + } + + private string resourceName { get; set; } = null; + internal bool IsResourceNameSet => resourceName != null; + + /// + /// The maximum number of records in each batch that Lambda pulls from the stream. + /// Default value is 100. + /// + public uint BatchSize + { + get => batchSize.GetValueOrDefault(); + set => batchSize = value; + } + private uint? batchSize { get; set; } + internal bool IsBatchSizeSet => batchSize.HasValue; + + /// + /// The position in the stream where Lambda starts reading. Valid values are TRIM_HORIZON and LATEST. + /// Default value is LATEST. + /// + public string StartingPosition { get; set; } = "LATEST"; + internal bool IsStartingPositionSet => true; + + /// + /// If set to false, the event source mapping will be disabled. Default value is true. + /// + public bool Enabled + { + get => enabled.GetValueOrDefault(); + set => enabled = value; + } + private bool? enabled { get; set; } + internal bool IsEnabledSet => enabled.HasValue; + + /// + /// The maximum amount of time, in seconds, to gather records before invoking the function. + /// + public uint MaximumBatchingWindowInSeconds + { + get => maximumBatchingWindowInSeconds.GetValueOrDefault(); + set => maximumBatchingWindowInSeconds = value; + } + private uint? maximumBatchingWindowInSeconds { get; set; } + internal bool IsMaximumBatchingWindowInSecondsSet => maximumBatchingWindowInSeconds.HasValue; + + /// + /// A collection of semicolon (;) separated strings where each string denotes a filter pattern. + /// + public string Filters { get; set; } = null; + internal bool IsFiltersSet => Filters != null; + + /// + /// Creates an instance of the class. + /// + /// property + public DynamoDBEventAttribute(string stream) + { + Stream = stream; + } + + internal List Validate() + { + var validationErrors = new List(); + + if (IsBatchSizeSet && (BatchSize < 1 || BatchSize > 10000)) + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.BatchSize)} = {BatchSize}. It must be between 1 and 10000"); + } + if (IsMaximumBatchingWindowInSecondsSet && MaximumBatchingWindowInSeconds > 300) + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.MaximumBatchingWindowInSeconds)} = {MaximumBatchingWindowInSeconds}. It must be between 0 and 300"); + } + if (!string.IsNullOrEmpty(StartingPosition) && StartingPosition != "TRIM_HORIZON" && StartingPosition != "LATEST") + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.StartingPosition)} = {StartingPosition}. It must be either TRIM_HORIZON or LATEST"); + } + if (!Stream.StartsWith("@")) + { + if (!Stream.Contains(":dynamodb:") && !Stream.Contains("/stream/")) + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.Stream)} = {Stream}. The DynamoDB stream ARN is invalid"); + } + } + if (IsResourceNameSet && !_resourceNameRegex.IsMatch(ResourceName)) + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.ResourceName)} = {ResourceName}. It must only contain alphanumeric characters and must not be an empty string"); + } + + return validationErrors; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Amazon.Lambda.RuntimeSupport.csproj b/Libraries/src/Amazon.Lambda.RuntimeSupport/Amazon.Lambda.RuntimeSupport.csproj index b3bfb0488..95c92dafb 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 1.14.2 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/src/SnapshotRestore.Registry/SnapshotRestore.Registry.csproj b/Libraries/src/SnapshotRestore.Registry/SnapshotRestore.Registry.csproj index 0bb3f5886..72aa0ef6b 100644 --- a/Libraries/src/SnapshotRestore.Registry/SnapshotRestore.Registry.csproj +++ b/Libraries/src/SnapshotRestore.Registry/SnapshotRestore.Registry.csproj @@ -3,7 +3,7 @@ - net8.0;net9.0;net10.0;net11.0 + net8.0;net9.0 1.0.1 Provides a Restore Hooks library to help you register before snapshot and after restore hooks. SnapshotRestore.Registry 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 c8cc6f306..a7a807214 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 @@ +