From ac02c35349adb42755f0ebeb37dbdee501ec7ff7 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Thu, 2 Apr 2026 22:24:51 +0000 Subject: [PATCH 1/3] Add [S3Event] annotation attribute and source generator support - S3EventAttribute with Bucket (required), ResourceName, Events, FilterPrefix, FilterSuffix, Enabled - S3EventAttributeBuilder for Roslyn AttributeData parsing - TypeFullNames constants and Events hashset registration - SyntaxReceiver secondary attribute registration - EventTypeBuilder S3 event type mapping - AttributeModelBuilder S3 branch - CloudFormationWriter ProcessS3Attribute (SAM S3 event with Ref, Events list, Filter rules) - LambdaFunctionValidator ValidateS3Events (params, return type, dependency check) - DiagnosticDescriptors InvalidS3EventAttribute (AWSLambda0133) Add S3Event annotation tests - ValidS3Events.cs.txt test source with 3 test functions - S3EventsTests.cs CloudFormation writer tests (attribute application + property sync) - S3Events project references in TestServerlessApp.csproj and test project IT test PR comments change file --- .autover/changes/add-s3event-annotation.json | 11 ++ .../Diagnostics/DiagnosticDescriptors.cs | 7 + .../Attributes/AttributeModelBuilder.cs | 10 ++ .../Attributes/S3EventAttributeBuilder.cs | 34 ++++ .../Models/EventTypeBuilder.cs | 4 + .../SyntaxReceiver.cs | 3 +- .../TypeFullNames.cs | 6 +- .../Validation/LambdaFunctionValidator.cs | 49 ++++++ .../Writers/CloudFormationWriter.cs | 54 ++++++ .../S3/S3EventAttribute.cs | 113 ++++++++++++ ....Annotations.SourceGenerators.Tests.csproj | 1 + .../WriterTests/S3EventsTests.cs | 166 ++++++++++++++++++ .../test/IntegrationTests.Helpers/S3Helper.cs | 8 + .../DeploymentScript.ps1 | 9 + .../IntegrationTestContextFixture.cs | 11 +- .../S3EventNotification.cs | 50 ++++++ .../S3EventExamples/S3EventProcessing.cs | 18 ++ .../S3EventExamples/ValidS3Events.cs.txt | 35 ++++ .../TestServerlessApp.csproj | 1 + .../TestServerlessApp/serverless.template | 57 ++++++ 20 files changed, 644 insertions(+), 3 deletions(-) create mode 100644 .autover/changes/add-s3event-annotation.json create mode 100644 Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/S3EventAttributeBuilder.cs create mode 100644 Libraries/src/Amazon.Lambda.Annotations/S3/S3EventAttribute.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/S3EventsTests.cs create mode 100644 Libraries/test/TestServerlessApp.IntegrationTests/S3EventNotification.cs create mode 100644 Libraries/test/TestServerlessApp/S3EventExamples/S3EventProcessing.cs create mode 100644 Libraries/test/TestServerlessApp/S3EventExamples/ValidS3Events.cs.txt 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..55edb0a7d 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,43 @@ 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 + 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))); + } + + // 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..3a2cdfa6a 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,55 @@ 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 + var bucketName = att.Bucket.StartsWith("@") ? att.Bucket.Substring(1) : att.Bucket; + _templateWriter.RemoveToken($"{eventPath}.Properties.Bucket"); + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, $"Bucket.{REF}", bucketName); + + // Events - list of S3 event types + if (att.IsEventsSet) + { + var events = att.Events.Split(';').Select(x => x.Trim()).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..c78abea37 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/S3/S3EventAttribute.cs @@ -0,0 +1,113 @@ +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 (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; set; } = "s3:ObjectCreated:*"; + 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 '@'"); + } + + 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 @@ +