diff --git a/.autover/changes/add-s3event-annotation.json b/.autover/changes/add-s3event-annotation.json new file mode 100644 index 000000000..90bdc8edf --- /dev/null +++ b/.autover/changes/add-s3event-annotation.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.Annotations", + "Type": "Minor", + "ChangelogMessages": [ + "Added [S3Event] annotation attribute for declaratively configuring S3 event-triggered Lambda functions with support for bucket reference, event types, key prefix/suffix filters, and enabled state." + ] + } + ] +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs index aef6767ce..e1a11087f 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs @@ -274,5 +274,12 @@ public static class DiagnosticDescriptors category: "AWSLambdaCSharpGenerator", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor InvalidS3EventAttribute = new DiagnosticDescriptor(id: "AWSLambda0136", + title: "Invalid S3EventAttribute", + messageFormat: "Invalid S3EventAttribute 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 add9e6c03..0d1067bb6 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs @@ -1,6 +1,7 @@ using System; using Amazon.Lambda.Annotations.ALB; using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.Annotations.S3; using Amazon.Lambda.Annotations.SQS; using Microsoft.CodeAnalysis; @@ -91,6 +92,15 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext Type = TypeModelBuilder.Build(att.AttributeClass, context) }; } + else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.S3EventAttribute), SymbolEqualityComparer.Default)) + { + var data = S3EventAttributeBuilder.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/S3EventAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/S3EventAttributeBuilder.cs new file mode 100644 index 000000000..503f8b201 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/S3EventAttributeBuilder.cs @@ -0,0 +1,34 @@ +using Amazon.Lambda.Annotations.S3; +using Microsoft.CodeAnalysis; +using System; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes +{ + public class S3EventAttributeBuilder + { + public static S3EventAttribute Build(AttributeData att) + { + if (att.ConstructorArguments.Length != 1) + throw new NotSupportedException($"{TypeFullNames.S3EventAttribute} must have constructor with 1 argument."); + + var bucket = att.ConstructorArguments[0].Value as string; + var data = new S3EventAttribute(bucket); + + foreach (var pair in att.NamedArguments) + { + if (pair.Key == nameof(data.ResourceName) && pair.Value.Value is string resourceName) + data.ResourceName = resourceName; + else if (pair.Key == nameof(data.Events) && pair.Value.Value is string events) + data.Events = events; + else if (pair.Key == nameof(data.FilterPrefix) && pair.Value.Value is string filterPrefix) + data.FilterPrefix = filterPrefix; + else if (pair.Key == nameof(data.FilterSuffix) && pair.Value.Value is string filterSuffix) + data.FilterSuffix = filterSuffix; + else if (pair.Key == nameof(data.Enabled) && pair.Value.Value is bool enabled) + data.Enabled = enabled; + } + + 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 06a2a0a1c..3dfc51799 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.S3EventAttribute) + { + events.Add(EventType.S3); + } 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 ff6e2ee08..2091d6c94 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs @@ -22,7 +22,8 @@ internal class SyntaxReceiver : ISyntaxContextReceiver { "HttpApiAttribute", "HttpApi" }, { "RestApiAttribute", "RestApi" }, { "SQSEventAttribute", "SQSEvent" }, - { "ALBApiAttribute", "ALBApi" } + { "ALBApiAttribute", "ALBApi" }, + { "S3EventAttribute", "S3Event" } }; 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 76871445e..59fa1d830 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs @@ -53,6 +53,9 @@ public static class TypeFullNames public const string ALBFromHeaderAttribute = "Amazon.Lambda.Annotations.ALB.FromHeaderAttribute"; public const string ALBFromBodyAttribute = "Amazon.Lambda.Annotations.ALB.FromBodyAttribute"; + public const string S3Event = "Amazon.Lambda.S3Events.S3Event"; + public const string S3EventAttribute = "Amazon.Lambda.Annotations.S3.S3EventAttribute"; + public const string LambdaSerializerAttribute = "Amazon.Lambda.Core.LambdaSerializerAttribute"; public const string DefaultLambdaSerializer = "Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer"; @@ -80,7 +83,8 @@ public static class TypeFullNames RestApiAttribute, HttpApiAttribute, SQSEventAttribute, - ALBApiAttribute + ALBApiAttribute, + S3EventAttribute }; } } \ 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 c496ac3bf..7e728660a 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs @@ -1,4 +1,5 @@ using Amazon.Lambda.Annotations.ALB; +using Amazon.Lambda.Annotations.S3; using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics; using Amazon.Lambda.Annotations.SourceGenerator.Extensions; using Amazon.Lambda.Annotations.SourceGenerator.Models; @@ -61,6 +62,7 @@ internal static bool ValidateFunction(GeneratorExecutionContext context, IMethod ValidateApiGatewayEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateSqsEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateAlbEvents(lambdaFunctionModel, methodLocation, diagnostics); + ValidateS3Events(lambdaFunctionModel, methodLocation, diagnostics); return ReportDiagnostics(diagnosticReporter, diagnostics); } @@ -98,6 +100,16 @@ internal static bool ValidateDependencies(GeneratorExecutionContext context, IMe } } + // Check for references to "Amazon.Lambda.S3Events" if the Lambda method is annotated with S3Event attribute. + if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.S3EventAttribute)) + { + if (context.Compilation.ReferencedAssemblyNames.FirstOrDefault(x => x.Name == "Amazon.Lambda.S3Events") == null) + { + diagnosticReporter.Report(Diagnostic.Create(DiagnosticDescriptors.MissingDependencies, methodLocation, "Amazon.Lambda.S3Events")); + return false; + } + } + return true; } @@ -362,6 +374,52 @@ private static void ValidateAlbEvents(LambdaFunctionModel lambdaFunctionModel, L } } + private static void ValidateS3Events(LambdaFunctionModel lambdaFunctionModel, Location methodLocation, List diagnostics) + { + if (!lambdaFunctionModel.LambdaMethod.Events.Contains(EventType.S3)) + return; + + // Validate S3EventAttributes + var seenResourceNames = new HashSet(); + foreach (var att in lambdaFunctionModel.Attributes) + { + if (att.Type.FullName != TypeFullNames.S3EventAttribute) + continue; + + var s3EventAttribute = ((AttributeModel)att).Data; + var validationErrors = s3EventAttribute.Validate(); + validationErrors.ForEach(errorMessage => diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidS3EventAttribute, methodLocation, errorMessage))); + + // Check for duplicate resource names (only when ResourceName is safe to evaluate) + var derivedResourceName = s3EventAttribute.ResourceName; + if (!string.IsNullOrEmpty(derivedResourceName) && !seenResourceNames.Add(derivedResourceName)) + { + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidS3EventAttribute, methodLocation, + $"Duplicate S3 event resource name '{derivedResourceName}'. Each [S3Event] attribute on the same method must have a unique ResourceName.")); + } + } + + // Validate method parameters - first param must be S3Event, optional second param ILambdaContext + var parameters = lambdaFunctionModel.LambdaMethod.Parameters; + if (parameters.Count == 0 || + parameters.Count > 2 || + (parameters.Count == 1 && parameters[0].Type.FullName != TypeFullNames.S3Event) || + (parameters.Count == 2 && (parameters[0].Type.FullName != TypeFullNames.S3Event || parameters[1].Type.FullName != TypeFullNames.ILambdaContext))) + { + var errorMessage = $"When using the {nameof(S3EventAttribute)}, the Lambda method can accept at most 2 parameters. " + + $"The first parameter is required and must be of type {TypeFullNames.S3Event}. " + + $"The second parameter is optional and must be of type {TypeFullNames.ILambdaContext}."; + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidLambdaMethodSignature, methodLocation, errorMessage)); + } + + // Validate method return type - must be void or Task + if (!lambdaFunctionModel.LambdaMethod.ReturnsVoid && !lambdaFunctionModel.LambdaMethod.ReturnsVoidTask) + { + var errorMessage = $"When using the {nameof(S3EventAttribute)}, 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 c384b7b48..a72e3241b 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs @@ -4,6 +4,7 @@ using Amazon.Lambda.Annotations.SourceGenerator.FileIO; using Amazon.Lambda.Annotations.SourceGenerator.Models; using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; +using Amazon.Lambda.Annotations.S3; using Amazon.Lambda.Annotations.SQS; using Microsoft.CodeAnalysis; using System; @@ -227,6 +228,10 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la var albResourceNames = ProcessAlbApiAttribute(lambdaFunction, albAttributeModel.Data); currentAlbResources.AddRange(albResourceNames); break; + case AttributeModel s3AttributeModel: + eventName = ProcessS3Attribute(lambdaFunction, s3AttributeModel.Data, currentSyncedEventProperties); + currentSyncedEvents.Add(eventName); + break; } } @@ -603,6 +608,54 @@ private string ProcessSqsAttribute(ILambdaFunctionSerializable lambdaFunction, S return att.ResourceName; } + /// + /// Writes all properties associated with to the serverless template. + /// + private string ProcessS3Attribute(ILambdaFunctionSerializable lambdaFunction, S3EventAttribute att, Dictionary> syncedEventProperties) + { + var eventName = att.ResourceName; + var eventPath = $"Resources.{lambdaFunction.ResourceName}.Properties.Events.{eventName}"; + + _templateWriter.SetToken($"{eventPath}.Type", "S3"); + + // Bucket - always a Ref since S3 events require the bucket resource in the same template (validated to start with "@") + var bucketName = att.Bucket.Substring(1); + _templateWriter.RemoveToken($"{eventPath}.Properties.Bucket"); + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, $"Bucket.{REF}", bucketName); + + // Events - list of S3 event types (always written since S3 SAM events require it; uses default "s3:ObjectCreated:*" if not explicitly set) + { + var events = att.Events.Split(';').Select(x => x.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "Events", events, TokenType.List); + } + + // Filter - S3 key filter rules + if (att.IsFilterPrefixSet || att.IsFilterSuffixSet) + { + var rules = new List>(); + + if (att.IsFilterPrefixSet) + { + rules.Add(new Dictionary { { "Name", "prefix" }, { "Value", att.FilterPrefix } }); + } + + if (att.IsFilterSuffixSet) + { + rules.Add(new Dictionary { { "Name", "suffix" }, { "Value", att.FilterSuffix } }); + } + + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "Filter.S3Key.Rules", rules, TokenType.List); + } + + // Enabled + if (att.IsEnabledSet) + { + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "Enabled", att.Enabled); + } + + return att.ResourceName; + } + /// /// Generates CloudFormation resources for an Application Load Balancer target. /// Unlike API Gateway events which map to SAM event types, ALB integration requires diff --git a/Libraries/src/Amazon.Lambda.Annotations/S3/S3EventAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/S3/S3EventAttribute.cs new file mode 100644 index 000000000..b005ee19e --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/S3/S3EventAttribute.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace Amazon.Lambda.Annotations.S3 +{ + /// + /// This attribute defines the S3 event source configuration for a Lambda function. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + public class S3EventAttribute : Attribute + { + private static readonly Regex _resourceNameRegex = new Regex("^[a-zA-Z0-9]+$"); + + /// + /// The S3 bucket that will act as the event trigger for the Lambda function. + /// This must be a reference to an S3 bucket resource defined in the serverless template, prefixed with "@". + /// + public string Bucket { get; set; } + + /// + /// The CloudFormation resource name for the S3 event. By default this is derived from the Bucket reference without the "@" prefix. + /// + public string ResourceName + { + get + { + if (IsResourceNameSet) + return resourceName; + if (!string.IsNullOrEmpty(Bucket) && Bucket.StartsWith("@")) + return Bucket.Substring(1); + return Bucket; + } + set => resourceName = value; + } + private string resourceName = null; + internal bool IsResourceNameSet => resourceName != null; + + /// + /// Semicolon-separated list of S3 event types. Default is 's3:ObjectCreated:*'. + /// + public string Events + { + get => events ?? "s3:ObjectCreated:*"; + set => events = value; + } + private string events = null; + internal bool IsEventsSet => events != null; + + /// + /// S3 key prefix filter for the event notification. + /// + public string FilterPrefix + { + get => filterPrefix; + set => filterPrefix = value; + } + private string filterPrefix = null; + internal bool IsFilterPrefixSet => filterPrefix != null; + + /// + /// S3 key suffix filter for the event notification. + /// + public string FilterSuffix + { + get => filterSuffix; + set => filterSuffix = value; + } + private string filterSuffix = null; + internal bool IsFilterSuffixSet => filterSuffix != null; + + /// + /// If set to false, the event source will be disabled. Default value is true. + /// + public bool Enabled + { + get => enabled.GetValueOrDefault(true); + set => enabled = value; + } + private bool? enabled; + internal bool IsEnabledSet => enabled.HasValue; + + /// + /// Creates an instance of the class. + /// + /// property + public S3EventAttribute(string bucket) + { + Bucket = bucket; + } + + internal List Validate() + { + var validationErrors = new List(); + + if (string.IsNullOrEmpty(Bucket)) + { + validationErrors.Add($"{nameof(S3EventAttribute.Bucket)} is required and must not be empty"); + } + else if (!Bucket.StartsWith("@")) + { + validationErrors.Add($"{nameof(S3EventAttribute.Bucket)} = {Bucket}. S3 event sources require a reference to an S3 bucket resource in the serverless template. Prefix the resource name with '@'"); + } + else + { + var bucketResourceName = Bucket.Substring(1); + if (!_resourceNameRegex.IsMatch(bucketResourceName)) + { + validationErrors.Add($"{nameof(S3EventAttribute.Bucket)} = {Bucket}. The referenced S3 bucket resource name must not be empty and must only contain alphanumeric characters after the '@' prefix"); + } + } + + if (IsResourceNameSet && !_resourceNameRegex.IsMatch(ResourceName)) + { + validationErrors.Add($"{nameof(S3EventAttribute.ResourceName)} = {ResourceName}. It must only contain alphanumeric characters and must not be an empty string"); + } + + if (string.IsNullOrEmpty(Events)) + { + validationErrors.Add($"{nameof(S3EventAttribute.Events)} must not be empty"); + } + + 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 b9b6a4113..56da7d597 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 @@ -209,6 +209,7 @@ +