Skip to content

Commit e06cfdd

Browse files
Add [S3Event] annotation attribute and source generator support (#2321)
* 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 fixes PR comments * add header
1 parent dcfacf2 commit e06cfdd

21 files changed

Lines changed: 1130 additions & 3 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"Projects": [
3+
{
4+
"Name": "Amazon.Lambda.Annotations",
5+
"Type": "Minor",
6+
"ChangelogMessages": [
7+
"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."
8+
]
9+
}
10+
]
11+
}

Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,5 +274,12 @@ public static class DiagnosticDescriptors
274274
category: "AWSLambdaCSharpGenerator",
275275
DiagnosticSeverity.Error,
276276
isEnabledByDefault: true);
277+
278+
public static readonly DiagnosticDescriptor InvalidS3EventAttribute = new DiagnosticDescriptor(id: "AWSLambda0136",
279+
title: "Invalid S3EventAttribute",
280+
messageFormat: "Invalid S3EventAttribute encountered: {0}",
281+
category: "AWSLambdaCSharpGenerator",
282+
DiagnosticSeverity.Error,
283+
isEnabledByDefault: true);
277284
}
278285
}

Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using Amazon.Lambda.Annotations.ALB;
33
using Amazon.Lambda.Annotations.APIGateway;
4+
using Amazon.Lambda.Annotations.S3;
45
using Amazon.Lambda.Annotations.SQS;
56
using Microsoft.CodeAnalysis;
67

@@ -91,6 +92,15 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext
9192
Type = TypeModelBuilder.Build(att.AttributeClass, context)
9293
};
9394
}
95+
else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.S3EventAttribute), SymbolEqualityComparer.Default))
96+
{
97+
var data = S3EventAttributeBuilder.Build(att);
98+
model = new AttributeModel<S3EventAttribute>
99+
{
100+
Data = data,
101+
Type = TypeModelBuilder.Build(att.AttributeClass, context)
102+
};
103+
}
94104
else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.HttpApiAuthorizerAttribute), SymbolEqualityComparer.Default))
95105
{
96106
var data = HttpApiAuthorizerAttributeBuilder.Build(att);
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
using Amazon.Lambda.Annotations.S3;
5+
using Microsoft.CodeAnalysis;
6+
using System;
7+
8+
namespace Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes
9+
{
10+
public class S3EventAttributeBuilder
11+
{
12+
public static S3EventAttribute Build(AttributeData att)
13+
{
14+
if (att.ConstructorArguments.Length != 1)
15+
throw new NotSupportedException($"{TypeFullNames.S3EventAttribute} must have constructor with 1 argument.");
16+
17+
var bucket = att.ConstructorArguments[0].Value as string;
18+
var data = new S3EventAttribute(bucket);
19+
20+
foreach (var pair in att.NamedArguments)
21+
{
22+
if (pair.Key == nameof(data.ResourceName) && pair.Value.Value is string resourceName)
23+
data.ResourceName = resourceName;
24+
else if (pair.Key == nameof(data.Events) && pair.Value.Value is string events)
25+
data.Events = events;
26+
else if (pair.Key == nameof(data.FilterPrefix) && pair.Value.Value is string filterPrefix)
27+
data.FilterPrefix = filterPrefix;
28+
else if (pair.Key == nameof(data.FilterSuffix) && pair.Value.Value is string filterSuffix)
29+
data.FilterSuffix = filterSuffix;
30+
else if (pair.Key == nameof(data.Enabled) && pair.Value.Value is bool enabled)
31+
data.Enabled = enabled;
32+
}
33+
34+
return data;
35+
}
36+
}
37+
}

Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ public static HashSet<EventType> Build(IMethodSymbol lambdaMethodSymbol,
2626
{
2727
events.Add(EventType.SQS);
2828
}
29+
else if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.S3EventAttribute)
30+
{
31+
events.Add(EventType.S3);
32+
}
2933
else if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.HttpApiAuthorizerAttribute
3034
|| attribute.AttributeClass.ToDisplayString() == TypeFullNames.RestApiAuthorizerAttribute)
3135
{

Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ internal class SyntaxReceiver : ISyntaxContextReceiver
2222
{ "HttpApiAttribute", "HttpApi" },
2323
{ "RestApiAttribute", "RestApi" },
2424
{ "SQSEventAttribute", "SQSEvent" },
25-
{ "ALBApiAttribute", "ALBApi" }
25+
{ "ALBApiAttribute", "ALBApi" },
26+
{ "S3EventAttribute", "S3Event" }
2627
};
2728

2829
public List<MethodDeclarationSyntax> LambdaMethods { get; } = new List<MethodDeclarationSyntax>();

Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ public static class TypeFullNames
5353
public const string ALBFromHeaderAttribute = "Amazon.Lambda.Annotations.ALB.FromHeaderAttribute";
5454
public const string ALBFromBodyAttribute = "Amazon.Lambda.Annotations.ALB.FromBodyAttribute";
5555

56+
public const string S3Event = "Amazon.Lambda.S3Events.S3Event";
57+
public const string S3EventAttribute = "Amazon.Lambda.Annotations.S3.S3EventAttribute";
58+
5659
public const string LambdaSerializerAttribute = "Amazon.Lambda.Core.LambdaSerializerAttribute";
5760
public const string DefaultLambdaSerializer = "Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer";
5861

@@ -80,7 +83,8 @@ public static class TypeFullNames
8083
RestApiAttribute,
8184
HttpApiAttribute,
8285
SQSEventAttribute,
83-
ALBApiAttribute
86+
ALBApiAttribute,
87+
S3EventAttribute
8488
};
8589
}
8690
}

Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Amazon.Lambda.Annotations.ALB;
2+
using Amazon.Lambda.Annotations.S3;
23
using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics;
34
using Amazon.Lambda.Annotations.SourceGenerator.Extensions;
45
using Amazon.Lambda.Annotations.SourceGenerator.Models;
@@ -61,6 +62,7 @@ internal static bool ValidateFunction(GeneratorExecutionContext context, IMethod
6162
ValidateApiGatewayEvents(lambdaFunctionModel, methodLocation, diagnostics);
6263
ValidateSqsEvents(lambdaFunctionModel, methodLocation, diagnostics);
6364
ValidateAlbEvents(lambdaFunctionModel, methodLocation, diagnostics);
65+
ValidateS3Events(lambdaFunctionModel, methodLocation, diagnostics);
6466

6567
return ReportDiagnostics(diagnosticReporter, diagnostics);
6668
}
@@ -98,6 +100,16 @@ internal static bool ValidateDependencies(GeneratorExecutionContext context, IMe
98100
}
99101
}
100102

103+
// Check for references to "Amazon.Lambda.S3Events" if the Lambda method is annotated with S3Event attribute.
104+
if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.S3EventAttribute))
105+
{
106+
if (context.Compilation.ReferencedAssemblyNames.FirstOrDefault(x => x.Name == "Amazon.Lambda.S3Events") == null)
107+
{
108+
diagnosticReporter.Report(Diagnostic.Create(DiagnosticDescriptors.MissingDependencies, methodLocation, "Amazon.Lambda.S3Events"));
109+
return false;
110+
}
111+
}
112+
101113
return true;
102114
}
103115

@@ -362,6 +374,52 @@ private static void ValidateAlbEvents(LambdaFunctionModel lambdaFunctionModel, L
362374
}
363375
}
364376

377+
private static void ValidateS3Events(LambdaFunctionModel lambdaFunctionModel, Location methodLocation, List<Diagnostic> diagnostics)
378+
{
379+
if (!lambdaFunctionModel.LambdaMethod.Events.Contains(EventType.S3))
380+
return;
381+
382+
// Validate S3EventAttributes
383+
var seenResourceNames = new HashSet<string>();
384+
foreach (var att in lambdaFunctionModel.Attributes)
385+
{
386+
if (att.Type.FullName != TypeFullNames.S3EventAttribute)
387+
continue;
388+
389+
var s3EventAttribute = ((AttributeModel<S3EventAttribute>)att).Data;
390+
var validationErrors = s3EventAttribute.Validate();
391+
validationErrors.ForEach(errorMessage => diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidS3EventAttribute, methodLocation, errorMessage)));
392+
393+
// Check for duplicate resource names (only when ResourceName is safe to evaluate)
394+
var derivedResourceName = s3EventAttribute.ResourceName;
395+
if (!string.IsNullOrEmpty(derivedResourceName) && !seenResourceNames.Add(derivedResourceName))
396+
{
397+
diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidS3EventAttribute, methodLocation,
398+
$"Duplicate S3 event resource name '{derivedResourceName}'. Each [S3Event] attribute on the same method must have a unique ResourceName."));
399+
}
400+
}
401+
402+
// Validate method parameters - first param must be S3Event, optional second param ILambdaContext
403+
var parameters = lambdaFunctionModel.LambdaMethod.Parameters;
404+
if (parameters.Count == 0 ||
405+
parameters.Count > 2 ||
406+
(parameters.Count == 1 && parameters[0].Type.FullName != TypeFullNames.S3Event) ||
407+
(parameters.Count == 2 && (parameters[0].Type.FullName != TypeFullNames.S3Event || parameters[1].Type.FullName != TypeFullNames.ILambdaContext)))
408+
{
409+
var errorMessage = $"When using the {nameof(S3EventAttribute)}, the Lambda method can accept at most 2 parameters. " +
410+
$"The first parameter is required and must be of type {TypeFullNames.S3Event}. " +
411+
$"The second parameter is optional and must be of type {TypeFullNames.ILambdaContext}.";
412+
diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidLambdaMethodSignature, methodLocation, errorMessage));
413+
}
414+
415+
// Validate method return type - must be void or Task
416+
if (!lambdaFunctionModel.LambdaMethod.ReturnsVoid && !lambdaFunctionModel.LambdaMethod.ReturnsVoidTask)
417+
{
418+
var errorMessage = $"When using the {nameof(S3EventAttribute)}, the Lambda method can return either void or {TypeFullNames.Task}";
419+
diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidLambdaMethodSignature, methodLocation, errorMessage));
420+
}
421+
}
422+
365423
private static bool ReportDiagnostics(DiagnosticReporter diagnosticReporter, List<Diagnostic> diagnostics)
366424
{
367425
var isValid = true;

Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Amazon.Lambda.Annotations.SourceGenerator.FileIO;
55
using Amazon.Lambda.Annotations.SourceGenerator.Models;
66
using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes;
7+
using Amazon.Lambda.Annotations.S3;
78
using Amazon.Lambda.Annotations.SQS;
89
using Microsoft.CodeAnalysis;
910
using System;
@@ -227,6 +228,10 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la
227228
var albResourceNames = ProcessAlbApiAttribute(lambdaFunction, albAttributeModel.Data);
228229
currentAlbResources.AddRange(albResourceNames);
229230
break;
231+
case AttributeModel<S3EventAttribute> s3AttributeModel:
232+
eventName = ProcessS3Attribute(lambdaFunction, s3AttributeModel.Data, currentSyncedEventProperties);
233+
currentSyncedEvents.Add(eventName);
234+
break;
230235
}
231236
}
232237

@@ -603,6 +608,54 @@ private string ProcessSqsAttribute(ILambdaFunctionSerializable lambdaFunction, S
603608
return att.ResourceName;
604609
}
605610

611+
/// <summary>
612+
/// Writes all properties associated with <see cref="S3EventAttribute"/> to the serverless template.
613+
/// </summary>
614+
private string ProcessS3Attribute(ILambdaFunctionSerializable lambdaFunction, S3EventAttribute att, Dictionary<string, List<string>> syncedEventProperties)
615+
{
616+
var eventName = att.ResourceName;
617+
var eventPath = $"Resources.{lambdaFunction.ResourceName}.Properties.Events.{eventName}";
618+
619+
_templateWriter.SetToken($"{eventPath}.Type", "S3");
620+
621+
// Bucket - always a Ref since S3 events require the bucket resource in the same template (validated to start with "@")
622+
var bucketName = att.Bucket.Substring(1);
623+
_templateWriter.RemoveToken($"{eventPath}.Properties.Bucket");
624+
SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, $"Bucket.{REF}", bucketName);
625+
626+
// Events - list of S3 event types (always written since S3 SAM events require it; uses default "s3:ObjectCreated:*" if not explicitly set)
627+
{
628+
var events = att.Events.Split(';').Select(x => x.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)).ToList();
629+
SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "Events", events, TokenType.List);
630+
}
631+
632+
// Filter - S3 key filter rules
633+
if (att.IsFilterPrefixSet || att.IsFilterSuffixSet)
634+
{
635+
var rules = new List<Dictionary<string, string>>();
636+
637+
if (att.IsFilterPrefixSet)
638+
{
639+
rules.Add(new Dictionary<string, string> { { "Name", "prefix" }, { "Value", att.FilterPrefix } });
640+
}
641+
642+
if (att.IsFilterSuffixSet)
643+
{
644+
rules.Add(new Dictionary<string, string> { { "Name", "suffix" }, { "Value", att.FilterSuffix } });
645+
}
646+
647+
SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "Filter.S3Key.Rules", rules, TokenType.List);
648+
}
649+
650+
// Enabled
651+
if (att.IsEnabledSet)
652+
{
653+
SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "Enabled", att.Enabled);
654+
}
655+
656+
return att.ResourceName;
657+
}
658+
606659
/// <summary>
607660
/// Generates CloudFormation resources for an Application Load Balancer target.
608661
/// Unlike API Gateway events which map to SAM event types, ALB integration requires

0 commit comments

Comments
 (0)