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 e9b44dd1e..76148cbbb 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md @@ -16,3 +16,4 @@ AWSLambda0128 | AWSLambdaCSharpGenerator | Error | Authorizer Payload Version Mi AWSLambda0129 | AWSLambdaCSharpGenerator | Error | Missing LambdaFunction Attribute AWSLambda0130 | AWSLambdaCSharpGenerator | Error | Invalid return type IAuthorizerResult AWSLambda0131 | AWSLambdaCSharpGenerator | Error | FromBody not supported on Authorizer functions +AWSLambda0134 | AWSLambdaCSharpGenerator | Error | Invalid SNSEventAttribute \ No newline at end of file diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs index 69c4f9428..a0c580ed0 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 InvalidSnsEventAttribute = new DiagnosticDescriptor(id: "AWSLambda0134", + title: "Invalid SNSEventAttribute", + messageFormat: "Invalid SNSEventAttribute 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..16afc8bc8 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.SNS; 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.SNSEventAttribute), SymbolEqualityComparer.Default)) + { + var data = SNSEventAttributeBuilder.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/SNSEventAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/SNSEventAttributeBuilder.cs new file mode 100644 index 000000000..9d6cb4dc8 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/SNSEventAttributeBuilder.cs @@ -0,0 +1,40 @@ +using Amazon.Lambda.Annotations.SNS; +using Microsoft.CodeAnalysis; +using System; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes +{ + /// + /// Builder for . + /// + public class SNSEventAttributeBuilder + { + public static SNSEventAttribute Build(AttributeData att) + { + if (att.ConstructorArguments.Length != 1) + { + throw new NotSupportedException($"{TypeFullNames.SNSEventAttribute} must have constructor with 1 argument."); + } + var topic = att.ConstructorArguments[0].Value as string; + var data = new SNSEventAttribute(topic); + + 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.FilterPolicy) && pair.Value.Value is string filterPolicy) + { + data.FilterPolicy = filterPolicy; + } + 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/EventType.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs index d231967e3..273a126f7 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs @@ -9,6 +9,7 @@ public enum EventType API, S3, SQS, + SNS, DynamoDB, Schedule, Authorizer diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs index 3f5775851..9bd5e0d71 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.SNSEventAttribute) + { + events.Add(EventType.SNS); + } 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..8152adea4 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" }, + { "SNSEventAttribute", "SNSEvent" } }; 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..d21a0bcd3 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 SNSEvent = "Amazon.Lambda.SNSEvents.SNSEvent"; + public const string SNSEventAttribute = "Amazon.Lambda.Annotations.SNS.SNSEventAttribute"; + 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, + SNSEventAttribute }; } } \ 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..7f1d8e949 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.SNS; 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); + ValidateSnsEvents(lambdaFunctionModel, methodLocation, diagnostics); return ReportDiagnostics(diagnosticReporter, diagnostics); } @@ -86,6 +88,16 @@ internal static bool ValidateDependencies(GeneratorExecutionContext context, IMe } } + // Check for references to "Amazon.Lambda.SNSEvents" if the Lambda method is annotated with SNSEvent attribute. + if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.SNSEventAttribute)) + { + if (context.Compilation.ReferencedAssemblyNames.FirstOrDefault(x => x.Name == "Amazon.Lambda.SNSEvents") == null) + { + diagnosticReporter.Report(Diagnostic.Create(DiagnosticDescriptors.MissingDependencies, methodLocation, "Amazon.Lambda.SNSEvents")); + return false; + } + } + return true; } @@ -268,6 +280,45 @@ private static void ValidateSqsEvents(LambdaFunctionModel lambdaFunctionModel, L } } + private static void ValidateSnsEvents(LambdaFunctionModel lambdaFunctionModel, Location methodLocation, List diagnostics) + { + if (!lambdaFunctionModel.LambdaMethod.Events.Contains(EventType.SNS)) + { + return; + } + + // Validate SNSEventAttributes + foreach (var att in lambdaFunctionModel.Attributes) + { + if (att.Type.FullName != TypeFullNames.SNSEventAttribute) + continue; + + var snsEventAttribute = ((AttributeModel)att).Data; + var validationErrors = snsEventAttribute.Validate(); + validationErrors.ForEach(errorMessage => diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidSnsEventAttribute, methodLocation, errorMessage))); + } + + // Validate method parameters - When using SNSEventAttribute, the method signature must be (SNSEvent snsEvent) or (SNSEvent snsEvent, ILambdaContext context) + var parameters = lambdaFunctionModel.LambdaMethod.Parameters; + if (parameters.Count == 0 || + parameters.Count > 2 || + (parameters.Count == 1 && parameters[0].Type.FullName != TypeFullNames.SNSEvent) || + (parameters.Count == 2 && (parameters[0].Type.FullName != TypeFullNames.SNSEvent || parameters[1].Type.FullName != TypeFullNames.ILambdaContext))) + { + var errorMessage = $"When using the {nameof(SNSEventAttribute)}, the Lambda method can accept at most 2 parameters. " + + $"The first parameter is required and must be of type {TypeFullNames.SNSEvent}. " + + $"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 SNSEventAttribute, the return type must be either void or Task + if (!lambdaFunctionModel.LambdaMethod.ReturnsVoid && !lambdaFunctionModel.LambdaMethod.ReturnsVoidTask) + { + var errorMessage = $"When using the {nameof(SNSEventAttribute)}, 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..17a0928fa 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs @@ -3,6 +3,7 @@ using Amazon.Lambda.Annotations.SourceGenerator.FileIO; using Amazon.Lambda.Annotations.SourceGenerator.Models; using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; +using Amazon.Lambda.Annotations.SNS; using Amazon.Lambda.Annotations.SQS; using Microsoft.CodeAnalysis; using System; @@ -221,6 +222,10 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la eventName = ProcessSqsAttribute(lambdaFunction, sqsAttributeModel.Data, currentSyncedEventProperties); currentSyncedEvents.Add(eventName); break; + case AttributeModel snsAttributeModel: + eventName = ProcessSnsAttribute(lambdaFunction, snsAttributeModel.Data, currentSyncedEventProperties); + currentSyncedEvents.Add(eventName); + break; } } @@ -596,6 +601,46 @@ private string ProcessSqsAttribute(ILambdaFunctionSerializable lambdaFunction, S return att.ResourceName; } + /// + /// Writes all properties associated with to the serverless template. + /// + private string ProcessSnsAttribute(ILambdaFunctionSerializable lambdaFunction, SNSEventAttribute att, Dictionary> syncedEventProperties) + { + var eventName = att.ResourceName; + var eventPath = $"Resources.{lambdaFunction.ResourceName}.Properties.Events.{eventName}"; + + _templateWriter.SetToken($"{eventPath}.Type", "SNS"); + + // Topic - SNS topics use Ref to get the ARN + _templateWriter.RemoveToken($"{eventPath}.Properties.Topic"); + if (!att.Topic.StartsWith("@")) + { + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "Topic", att.Topic); + } + else + { + var topic = att.Topic.Substring(1); + if (_templateWriter.Exists($"{PARAMETERS}.{topic}")) + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, $"Topic.{REF}", topic); + else + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, $"Topic.{REF}", topic); + } + + // FilterPolicy + if (att.IsFilterPolicySet) + { + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "FilterPolicy", att.FilterPolicy); + } + + // Enabled + if (att.IsEnabledSet) + { + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "Enabled", att.Enabled); + } + + return att.ResourceName; + } + /// /// Writes all properties associated with to the serverless template. /// diff --git a/Libraries/src/Amazon.Lambda.Annotations/SNS/SNSEventAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/SNS/SNSEventAttribute.cs new file mode 100644 index 000000000..980e880cc --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/SNS/SNSEventAttribute.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Amazon.Lambda.Annotations.SNS +{ + /// + /// This attribute defines the SNS event source configuration for a Lambda function. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + public class SNSEventAttribute : Attribute + { + private static readonly Regex _resourceNameRegex = new Regex("^[a-zA-Z0-9]+$"); + + /// + /// The SNS topic that will act as the event trigger for the Lambda function. + /// This can either be the topic ARN or reference to the SNS topic resource that is already defined in the serverless template. + /// To reference an SNS topic resource in the serverless template, prefix the resource name with "@" symbol. + /// + public string Topic { get; set; } + + /// + /// The CloudFormation resource name for the SNS event. By default this is set to the SNS topic name if the is set to an SNS topic ARN. + /// If is set to an existing CloudFormation resource, than that is used as the default value without the "@" prefix. + /// + public string ResourceName + { + get + { + if (IsResourceNameSet) + { + return resourceName; + } + if (Topic.StartsWith("@")) + { + return Topic.Substring(1); + } + + var arnTokens = Topic.Split(new char[] { ':' }, 6); + var topicName = arnTokens[5]; + var sanitizedTopicName = string.Join(string.Empty, topicName.Where(char.IsLetterOrDigit)); + return sanitizedTopicName; + } + set => resourceName = value; + } + + private string resourceName { get; set; } = null; + internal bool IsResourceNameSet => resourceName != null; + + /// + /// A JSON filter policy that is applied to the SNS subscription. + /// Only messages matching the filter policy will be delivered to the Lambda function. + /// + public string FilterPolicy { get; set; } = null; + internal bool IsFilterPolicySet => FilterPolicy != null; + + /// + /// If set to false, the event source 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; + + /// + /// Creates an instance of the class. + /// + /// property + public SNSEventAttribute(string topic) + { + Topic = topic; + } + + internal List Validate() + { + var validationErrors = new List(); + + if (!Topic.StartsWith("@")) + { + var arnTokens = Topic.Split(new char[] { ':' }, 6); + if (arnTokens.Length != 6) + { + validationErrors.Add($"{nameof(SNSEventAttribute.Topic)} = {Topic}. The SNS topic ARN is invalid. The ARN format is 'arn::sns:::'"); + } + } + if (IsResourceNameSet && !_resourceNameRegex.IsMatch(ResourceName)) + { + validationErrors.Add($"{nameof(SNSEventAttribute.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/WriterTests/SNSEventsTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/SNSEventsTests.cs new file mode 100644 index 000000000..f344c2600 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/SNSEventsTests.cs @@ -0,0 +1,257 @@ +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.SNS; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests.WriterTests +{ + public partial class CloudFormationWriterTests + { + const string topicArn1 = "arn:aws:sns:us-east-2:444455556666:topic1"; + const string topicArn2 = "arn:aws:sns:us-east-2:444455556666:topic2"; + + [Theory] + [ClassData(typeof(SnsEventsTestData))] + public void VerifySNSEventAttributes_AreCorrectlyApplied(CloudFormationTemplateFormat templateFormat, IEnumerable snsEventAttributes) + { + // ARRANGE + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel(); + lambdaFunctionModel.PackageType = LambdaPackageType.Zip; + foreach (var att in snsEventAttributes) + { + 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 snsEventAttributes) + { + var eventName = att.Topic.StartsWith("@") ? att.Topic.Substring(1) : att.Topic.Split(':').ToList()[5]; + var eventPath = $"Resources.{lambdaFunctionModel.ResourceName}.Properties.Events.{eventName}"; + var eventPropertiesPath = $"{eventPath}.Properties"; + + Assert.True(templateWriter.Exists(eventPath)); + Assert.Equal("SNS", templateWriter.GetToken($"{eventPath}.Type")); + + if (!att.Topic.StartsWith("@")) + { + Assert.Equal(att.Topic, templateWriter.GetToken($"{eventPropertiesPath}.Topic")); + } + else + { + Assert.Equal(att.Topic.Substring(1), templateWriter.GetToken($"{eventPropertiesPath}.Topic.Ref")); + } + + Assert.Equal(att.IsFilterPolicySet, templateWriter.Exists($"{eventPropertiesPath}.FilterPolicy")); + if (att.IsFilterPolicySet) + { + Assert.Equal(att.FilterPolicy, templateWriter.GetToken($"{eventPropertiesPath}.FilterPolicy")); + } + + Assert.Equal(att.IsEnabledSet, templateWriter.Exists($"{eventPropertiesPath}.Enabled")); + if (att.IsEnabledSet) + { + Assert.Equal(att.Enabled, templateWriter.GetToken($"{eventPropertiesPath}.Enabled")); + } + } + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void VerifySNSEventProperties_AreSyncedCorrectly(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 + 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")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.Enabled")); + + var syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Equal(2, syncedEventProperties[eventResourceName].Count); + Assert.Contains("Topic", syncedEventProperties[eventResourceName]); + Assert.Contains("FilterPolicy", syncedEventProperties[eventResourceName]); + + // Update attribute - remove FilterPolicy, add Enabled + var updatedAttribute = new SNSEventAttribute(topicArn2) + { + ResourceName = eventResourceName, + Enabled = false + }; + lambdaFunctionModel.Attributes = [new AttributeModel { Data = updatedAttribute }]; + report = GetAnnotationReport([lambdaFunctionModel]); + + cloudFormationWriter.ApplyReport(report); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + + Assert.Equal(topicArn2, templateWriter.GetToken($"{eventPropertiesPath}.Topic")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.FilterPolicy")); + Assert.False(templateWriter.GetToken($"{eventPropertiesPath}.Enabled")); + + syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Equal(2, syncedEventProperties[eventResourceName].Count); + Assert.Contains("Topic", syncedEventProperties[eventResourceName]); + Assert.Contains("Enabled", syncedEventProperties[eventResourceName]); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void VerifySNSTopicCanBeSet_FromCloudFormationParameter(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE + const string jsonContent = @"{ + 'Parameters':{ + 'MyTopic':{ + 'Type':'String', + 'Default':'arn:aws:sns:us-east-2:444455556666:topic1' + } + } + }"; + + const string yamlContent = @"Parameters: + MyTopic: + Type: String + Default: arn:aws:sns:us-east-2:444455556666:topic1"; + + 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 = "MySNSEvent"; + var snsEventAttribute = new SNSEventAttribute("@MyTopic") { ResourceName = eventResourceName }; + lambdaFunctionModel.Attributes = [new AttributeModel { Data = snsEventAttribute }]; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport([lambdaFunctionModel]); + + var snsEventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Properties.Events.{eventResourceName}.Properties"; + var syncedEventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Metadata.SyncedEventProperties"; + + // ACT + cloudFormationWriter.ApplyReport(report); + + // ASSERT - Topic uses Ref (SNS topics use Ref to get the ARN) + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal("MyTopic", templateWriter.GetToken($"{snsEventPropertiesPath}.Topic.Ref")); + Assert.False(templateWriter.Exists($"{snsEventPropertiesPath}.Topic.Fn::GetAtt")); + + var syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Single(syncedEventProperties[eventResourceName]); + Assert.Equal("Topic.Ref", syncedEventProperties[eventResourceName][0]); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void SwitchBetweenArnAndRef_ForTopic(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 = "MySNSEvent"; + + var syncedEventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Metadata.SyncedEventProperties"; + var snsEventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Properties.Events.{eventResourceName}.Properties"; + + // Start with Topic ARN + var snsEventAttribute = new SNSEventAttribute(topicArn1) { ResourceName = eventResourceName }; + lambdaFunctionModel.Attributes = [new AttributeModel { Data = snsEventAttribute }]; + + // Act + var report = GetAnnotationReport([lambdaFunctionModel]); + cloudFormationWriter.ApplyReport(report); + + // Assert - Topic is ARN + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal(topicArn1, templateWriter.GetToken($"{snsEventPropertiesPath}.Topic")); + Assert.False(templateWriter.Exists($"{snsEventPropertiesPath}.Topic.Ref")); + + var syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Single(syncedEventProperties[eventResourceName]); + Assert.Equal("Topic", syncedEventProperties[eventResourceName][0]); + + // Switch to Topic reference + snsEventAttribute.Topic = "@MyTopic"; + cloudFormationWriter.ApplyReport(report); + + // Assert - Topic is Ref + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal("MyTopic", templateWriter.GetToken($"{snsEventPropertiesPath}.Topic.Ref")); + + syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Single(syncedEventProperties[eventResourceName]); + Assert.Equal("Topic.Ref", syncedEventProperties[eventResourceName][0]); + } + + public class SnsEventsTestData : TheoryData> + { + public SnsEventsTestData() + { + foreach (var templateFormat in new List { CloudFormationTemplateFormat.Json, CloudFormationTemplateFormat.Yaml }) + { + // Simple attribute + Add(templateFormat, [new(topicArn1)]); + + // Multiple SNSEvent attributes + Add(templateFormat, [new(topicArn1), new(topicArn2)]); + + // Use topic reference + Add(templateFormat, [new("@MyTopic")]); + + // Use both ARN and topic reference + Add(templateFormat, [new(topicArn1), new("@MyTopic")]); + + // Specify filter policy + Add(templateFormat, [new(topicArn1) { FilterPolicy = "{ \"store\": [\"example_corp\"] }" }]); + + // Explicitly specify all properties + Add(templateFormat, + [new(topicArn1) + { + FilterPolicy = "{ \"store\": [\"example_corp\"] }", + Enabled = false + }]); + } + } + } + } +} diff --git a/Libraries/test/TestServerlessApp/SnsMessageProcessing.cs b/Libraries/test/TestServerlessApp/SnsMessageProcessing.cs new file mode 100644 index 000000000..a1947c5c3 --- /dev/null +++ b/Libraries/test/TestServerlessApp/SnsMessageProcessing.cs @@ -0,0 +1,17 @@ +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.SNS; +using Amazon.Lambda.Core; +using Amazon.Lambda.SNSEvents; + +namespace TestServerlessApp +{ + public class SnsMessageProcessing + { + [LambdaFunction(ResourceName = "SNSMessageHandler", Policies = "AWSLambdaSNSTopicExecutionRole")] + [SNSEvent("@TestTopic", ResourceName = "TestTopicEvent", FilterPolicy = "{ \"store\": [\"example_corp\"] }")] + public void HandleMessage(SNSEvent evnt, ILambdaContext lambdaContext) + { + lambdaContext.Logger.Log($"Received {evnt.Records.Count} messages"); + } + } +} diff --git a/Libraries/test/TestServerlessApp/TestServerlessApp.csproj b/Libraries/test/TestServerlessApp/TestServerlessApp.csproj index 921e3d372..802300a73 100644 --- a/Libraries/test/TestServerlessApp/TestServerlessApp.csproj +++ b/Libraries/test/TestServerlessApp/TestServerlessApp.csproj @@ -27,6 +27,7 @@ +