From 403d00e4f8571750394cec3b3650e8975a9b5ce7 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Thu, 2 Apr 2026 20:56:50 +0000 Subject: [PATCH 1/8] Phase 1: Add FunctionUrlAttribute with source generator wiring and CloudFormation FunctionUrlConfig generation - New FunctionUrlAttribute class with AuthType property (NONE/AWS_IAM) - New FunctionUrlAuthType enum - Source generator detects FunctionUrlAttribute and maps to EventType.API - Generated wrapper uses HttpApi V2 request/response types (same payload format) - CloudFormationWriter emits FunctionUrlConfig on the function resource - Dependency validation checks for Amazon.Lambda.APIGatewayEvents - SyntaxReceiver detects missing [LambdaFunction] on [FunctionUrl] methods - 6 new unit tests for CloudFormation template generation (JSON + YAML) --- .../Attributes/AttributeModelBuilder.cs | 9 ++ .../Attributes/FunctionUrlAttributeBuilder.cs | 19 ++++ .../Models/EventTypeBuilder.cs | 3 +- .../Models/GeneratedMethodModelBuilder.cs | 22 +++++ .../SyntaxReceiver.cs | 1 + .../TypeFullNames.cs | 4 + .../Validation/LambdaFunctionValidator.cs | 1 + .../Writers/CloudFormationWriter.cs | 14 +++ .../APIGateway/FunctionUrlAttribute.cs | 17 ++++ .../APIGateway/FunctionUrlAuthType.cs | 18 ++++ .../WriterTests/FunctionUrlTests.cs | 98 +++++++++++++++++++ 11 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs create mode 100644 Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAttribute.cs create mode 100644 Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAuthType.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs 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..cf7c7805b 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs @@ -90,6 +90,15 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext Type = TypeModelBuilder.Build(att.AttributeClass, context) }; } + else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.FunctionUrlAttribute), SymbolEqualityComparer.Default)) + { + var data = FunctionUrlAttributeBuilder.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/FunctionUrlAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs new file mode 100644 index 000000000..87a461043 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs @@ -0,0 +1,19 @@ +using System.Linq; +using Amazon.Lambda.Annotations.APIGateway; +using Microsoft.CodeAnalysis; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes +{ + public static class FunctionUrlAttributeBuilder + { + public static FunctionUrlAttribute Build(AttributeData att) + { + var authType = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AuthType").Value.Value; + + return new FunctionUrlAttribute + { + AuthType = authType == null ? FunctionUrlAuthType.NONE : (FunctionUrlAuthType)authType + }; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs index 3f5775851..427cfad34 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs @@ -18,7 +18,8 @@ public static HashSet Build(IMethodSymbol lambdaMethodSymbol, foreach (var attribute in lambdaMethodSymbol.GetAttributes()) { if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.RestApiAttribute - || attribute.AttributeClass.ToDisplayString() == TypeFullNames.HttpApiAttribute) + || attribute.AttributeClass.ToDisplayString() == TypeFullNames.HttpApiAttribute + || attribute.AttributeClass.ToDisplayString() == TypeFullNames.FunctionUrlAttribute) { events.Add(EventType.API); } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs index decb864ee..5dbad98f6 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs @@ -130,6 +130,14 @@ private static TypeModel BuildResponseType(IMethodSymbol lambdaMethodSymbol, throw new ArgumentOutOfRangeException(); } } + else if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.FunctionUrlAttribute)) + { + // Function URLs use the same payload format as HTTP API v2 + var symbol = lambdaMethodModel.ReturnsVoidOrGenericTask ? + task.Construct(context.Compilation.GetTypeByMetadataName(TypeFullNames.APIGatewayHttpApiV2ProxyResponse)): + context.Compilation.GetTypeByMetadataName(TypeFullNames.APIGatewayHttpApiV2ProxyResponse); + return TypeModelBuilder.Build(symbol, context); + } else { return lambdaMethodModel.ReturnType; @@ -277,6 +285,20 @@ private static IList BuildParameters(IMethodSymbol lambdaMethodS parameters.Add(requestParameter); parameters.Add(contextParameter); } + else if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.FunctionUrlAttribute)) + { + // Function URLs use the same payload format as HTTP API v2 + var symbol = context.Compilation.GetTypeByMetadataName(TypeFullNames.APIGatewayHttpApiV2ProxyRequest); + var type = TypeModelBuilder.Build(symbol, context); + var requestParameter = new ParameterModel + { + Name = "__request__", + Type = type, + Documentation = "The Function URL request object that will be processed by the Lambda function handler." + }; + parameters.Add(requestParameter); + parameters.Add(contextParameter); + } else { // Lambda method with no event attribute are plain lambda functions, therefore, generated method will have diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs index a5d7ce9ab..0b8851c5c 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs @@ -21,6 +21,7 @@ internal class SyntaxReceiver : ISyntaxContextReceiver { "RestApiAuthorizerAttribute", "RestApiAuthorizer" }, { "HttpApiAttribute", "HttpApi" }, { "RestApiAttribute", "RestApi" }, + { "FunctionUrlAttribute", "FunctionUrl" }, { "SQSEventAttribute", "SQSEvent" } }; diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs index 6e15c2175..69a1bc083 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs @@ -34,6 +34,9 @@ public static class TypeFullNames public const string FromRouteAttribute = "Amazon.Lambda.Annotations.APIGateway.FromRouteAttribute"; public const string FromCustomAuthorizerAttribute = "Amazon.Lambda.Annotations.APIGateway.FromCustomAuthorizerAttribute"; + public const string FunctionUrlAttribute = "Amazon.Lambda.Annotations.APIGateway.FunctionUrlAttribute"; + public const string FunctionUrlAuthType = "Amazon.Lambda.Annotations.APIGateway.FunctionUrlAuthType"; + public const string HttpApiAuthorizerAttribute = "Amazon.Lambda.Annotations.APIGateway.HttpApiAuthorizerAttribute"; public const string RestApiAuthorizerAttribute = "Amazon.Lambda.Annotations.APIGateway.RestApiAuthorizerAttribute"; @@ -67,6 +70,7 @@ public static class TypeFullNames { RestApiAttribute, HttpApiAttribute, + FunctionUrlAttribute, SQSEventAttribute }; } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs index 733124209..525811f67 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs @@ -67,6 +67,7 @@ internal static bool ValidateDependencies(GeneratorExecutionContext context, IMe { // Check for references to "Amazon.Lambda.APIGatewayEvents" if the Lambda method is annotated with RestApi, HttpApi, or authorizer attributes. if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.RestApiAttribute) || lambdaMethodSymbol.HasAttribute(context, TypeFullNames.HttpApiAttribute) + || lambdaMethodSymbol.HasAttribute(context, TypeFullNames.FunctionUrlAttribute) || lambdaMethodSymbol.HasAttribute(context, TypeFullNames.HttpApiAuthorizerAttribute) || lambdaMethodSymbol.HasAttribute(context, TypeFullNames.RestApiAuthorizerAttribute)) { if (context.Compilation.ReferencedAssemblyNames.FirstOrDefault(x => x.Name == "Amazon.Lambda.APIGatewayEvents") == null) diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs index a59aaf6d4..9361b9c31 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs @@ -221,6 +221,9 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la eventName = ProcessSqsAttribute(lambdaFunction, sqsAttributeModel.Data, currentSyncedEventProperties); currentSyncedEvents.Add(eventName); break; + case AttributeModel functionUrlAttributeModel: + ProcessFunctionUrlAttribute(lambdaFunction, functionUrlAttributeModel.Data); + break; } } @@ -290,6 +293,17 @@ private string ProcessHttpApiAttribute(ILambdaFunctionSerializable lambdaFunctio return eventName; } + /// + /// Writes the configuration to the serverless template. + /// Unlike HttpApi/RestApi, Function URLs are configured as a property on the function resource + /// rather than as an event source. + /// + private void ProcessFunctionUrlAttribute(ILambdaFunctionSerializable lambdaFunction, FunctionUrlAttribute functionUrlAttribute) + { + var functionUrlConfigPath = $"Resources.{lambdaFunction.ResourceName}.Properties.FunctionUrlConfig"; + _templateWriter.SetToken($"{functionUrlConfigPath}.AuthType", functionUrlAttribute.AuthType.ToString()); + } + /// /// Processes all authorizers and writes them to the serverless template as inline authorizers within the API resources. /// AWS SAM expects authorizers to be defined within the Auth.Authorizers property of AWS::Serverless::HttpApi or AWS::Serverless::Api resources. diff --git a/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAttribute.cs new file mode 100644 index 000000000..49575b001 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAttribute.cs @@ -0,0 +1,17 @@ +using System; + +namespace Amazon.Lambda.Annotations.APIGateway +{ + /// + /// Configures the Lambda function to be invoked via a Lambda Function URL. + /// + /// + /// Function URLs use the same payload format as HTTP API v2 (APIGatewayHttpApiV2ProxyRequest/Response). + /// + [AttributeUsage(AttributeTargets.Method)] + public class FunctionUrlAttribute : Attribute + { + /// + public FunctionUrlAuthType AuthType { get; set; } = FunctionUrlAuthType.NONE; + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAuthType.cs b/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAuthType.cs new file mode 100644 index 000000000..447104448 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAuthType.cs @@ -0,0 +1,18 @@ +namespace Amazon.Lambda.Annotations.APIGateway +{ + /// + /// The type of authentication for a Lambda Function URL. + /// + public enum FunctionUrlAuthType + { + /// + /// No authentication. Anyone with the Function URL can invoke the function. + /// + NONE, + + /// + /// IAM authentication. Only authenticated IAM users and roles can invoke the function. + /// + AWS_IAM + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs new file mode 100644 index 000000000..6a9e4780f --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using Amazon.Lambda.Annotations.APIGateway; +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 Xunit; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests.WriterTests +{ + public partial class CloudFormationWriterTests + { + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlWithDefaultAuthType(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute() + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + // ACT + cloudFormationWriter.ApplyReport(report); + + // ASSERT + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal("NONE", templateWriter.GetToken("Resources.TestMethod.Properties.FunctionUrlConfig.AuthType")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlWithIamAuth(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute { AuthType = FunctionUrlAuthType.AWS_IAM } + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + // ACT + cloudFormationWriter.ApplyReport(report); + + // ASSERT + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal("AWS_IAM", templateWriter.GetToken("Resources.TestMethod.Properties.FunctionUrlConfig.AuthType")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlDoesNotCreateEventEntry(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE - FunctionUrl should NOT create an Events entry (unlike HttpApi/RestApi) + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute() + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + // ACT + cloudFormationWriter.ApplyReport(report); + + // ASSERT + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.Events")); + } + } +} From ef94a0ecd0eea60e12bc1b4889719890f3790757 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Thu, 2 Apr 2026 20:58:27 +0000 Subject: [PATCH 2/8] Phase 2: Add CORS support to FunctionUrlAttribute - AllowOrigins, AllowMethods, AllowHeaders, ExposeHeaders, AllowCredentials, MaxAge properties - FunctionUrlAttributeBuilder parses all CORS properties from AttributeData - CloudFormationWriter emits Cors block under FunctionUrlConfig only when CORS properties are set - 4 new unit tests for CORS generation and no-CORS scenarios --- .../Attributes/FunctionUrlAttributeBuilder.cs | 29 +++++++- .../Writers/CloudFormationWriter.cs | 30 ++++++++ .../APIGateway/FunctionUrlAttribute.cs | 31 ++++++++ .../Amazon.Lambda.RuntimeSupport.csproj | 2 +- .../SnapshotRestore.Registry.csproj | 2 +- .../WriterTests/FunctionUrlTests.cs | 70 +++++++++++++++++++ 6 files changed, 161 insertions(+), 3 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs index 87a461043..c223beee2 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Linq; using Amazon.Lambda.Annotations.APIGateway; using Microsoft.CodeAnalysis; @@ -10,10 +11,36 @@ public static FunctionUrlAttribute Build(AttributeData att) { var authType = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AuthType").Value.Value; - return new FunctionUrlAttribute + var data = new FunctionUrlAttribute { AuthType = authType == null ? FunctionUrlAuthType.NONE : (FunctionUrlAuthType)authType }; + + var allowOrigins = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AllowOrigins").Value; + if (!allowOrigins.IsNull) + data.AllowOrigins = allowOrigins.Values.Select(v => v.Value as string).ToArray(); + + var allowMethods = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AllowMethods").Value; + if (!allowMethods.IsNull) + data.AllowMethods = allowMethods.Values.Select(v => v.Value as string).ToArray(); + + var allowHeaders = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AllowHeaders").Value; + if (!allowHeaders.IsNull) + data.AllowHeaders = allowHeaders.Values.Select(v => v.Value as string).ToArray(); + + var exposeHeaders = att.NamedArguments.FirstOrDefault(arg => arg.Key == "ExposeHeaders").Value; + if (!exposeHeaders.IsNull) + data.ExposeHeaders = exposeHeaders.Values.Select(v => v.Value as string).ToArray(); + + var allowCredentials = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AllowCredentials").Value.Value; + if (allowCredentials != null) + data.AllowCredentials = (bool)allowCredentials; + + var maxAge = att.NamedArguments.FirstOrDefault(arg => arg.Key == "MaxAge").Value.Value; + if (maxAge != null) + data.MaxAge = (int)maxAge; + + return data; } } } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs index 9361b9c31..15cb56a13 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs @@ -302,6 +302,36 @@ private void ProcessFunctionUrlAttribute(ILambdaFunctionSerializable lambdaFunct { var functionUrlConfigPath = $"Resources.{lambdaFunction.ResourceName}.Properties.FunctionUrlConfig"; _templateWriter.SetToken($"{functionUrlConfigPath}.AuthType", functionUrlAttribute.AuthType.ToString()); + + var hasCors = functionUrlAttribute.AllowOrigins != null + || functionUrlAttribute.AllowMethods != null + || functionUrlAttribute.AllowHeaders != null + || functionUrlAttribute.ExposeHeaders != null + || functionUrlAttribute.AllowCredentials + || functionUrlAttribute.MaxAge > 0; + + if (hasCors) + { + var corsPath = $"{functionUrlConfigPath}.Cors"; + + if (functionUrlAttribute.AllowOrigins != null) + _templateWriter.SetToken($"{corsPath}.AllowOrigins", new List(functionUrlAttribute.AllowOrigins), TokenType.List); + + if (functionUrlAttribute.AllowMethods != null) + _templateWriter.SetToken($"{corsPath}.AllowMethods", new List(functionUrlAttribute.AllowMethods), TokenType.List); + + if (functionUrlAttribute.AllowHeaders != null) + _templateWriter.SetToken($"{corsPath}.AllowHeaders", new List(functionUrlAttribute.AllowHeaders), TokenType.List); + + if (functionUrlAttribute.ExposeHeaders != null) + _templateWriter.SetToken($"{corsPath}.ExposeHeaders", new List(functionUrlAttribute.ExposeHeaders), TokenType.List); + + if (functionUrlAttribute.AllowCredentials) + _templateWriter.SetToken($"{corsPath}.AllowCredentials", true); + + if (functionUrlAttribute.MaxAge > 0) + _templateWriter.SetToken($"{corsPath}.MaxAge", functionUrlAttribute.MaxAge); + } } /// diff --git a/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAttribute.cs index 49575b001..0f68706d9 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAttribute.cs +++ b/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAttribute.cs @@ -13,5 +13,36 @@ public class FunctionUrlAttribute : Attribute { /// public FunctionUrlAuthType AuthType { get; set; } = FunctionUrlAuthType.NONE; + + /// + /// The allowed origins for CORS requests. Example: new[] { "https://example.com" } + /// + public string[] AllowOrigins { get; set; } + + /// + /// The allowed HTTP methods for CORS requests. Example: new[] { "GET", "POST" } + /// + public string[] AllowMethods { get; set; } + + /// + /// The allowed headers for CORS requests. + /// + public string[] AllowHeaders { get; set; } + + /// + /// Whether credentials are included in the CORS request. + /// + public bool AllowCredentials { get; set; } + + /// + /// The expose headers for CORS responses. + /// + public string[] ExposeHeaders { get; set; } + + /// + /// The maximum time in seconds that a browser can cache the CORS preflight response. + /// A value of 0 means the property is not set. + /// + public int MaxAge { get; set; } } } 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/WriterTests/FunctionUrlTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs index 6a9e4780f..ebdafc679 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs @@ -94,5 +94,75 @@ public void FunctionUrlDoesNotCreateEventEntry(CloudFormationTemplateFormat temp Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.Events")); } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlWithCors(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute + { + AuthType = FunctionUrlAuthType.NONE, + AllowOrigins = new[] { "https://example.com" }, + AllowMethods = new[] { "GET", "POST" }, + AllowHeaders = new[] { "Content-Type", "Authorization" }, + AllowCredentials = true, + MaxAge = 3600 + } + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + // ACT + cloudFormationWriter.ApplyReport(report); + + // ASSERT + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + var corsPath = "Resources.TestMethod.Properties.FunctionUrlConfig.Cors"; + Assert.Equal(new List { "https://example.com" }, templateWriter.GetToken>($"{corsPath}.AllowOrigins")); + Assert.Equal(new List { "GET", "POST" }, templateWriter.GetToken>($"{corsPath}.AllowMethods")); + Assert.Equal(new List { "Content-Type", "Authorization" }, templateWriter.GetToken>($"{corsPath}.AllowHeaders")); + Assert.True(templateWriter.GetToken($"{corsPath}.AllowCredentials")); + Assert.Equal(3600, templateWriter.GetToken($"{corsPath}.MaxAge")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlWithoutCorsDoesNotEmitCorsBlock(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE - No CORS properties set, so no Cors block should be emitted + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute { AuthType = FunctionUrlAuthType.AWS_IAM } + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + // ACT + cloudFormationWriter.ApplyReport(report); + + // ASSERT + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig.AuthType")); + Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig.Cors")); + } } } From a20a2b2a49dc490c0e4b62203cabbf30cccbb5db Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Thu, 2 Apr 2026 21:00:16 +0000 Subject: [PATCH 3/8] Phase 3: FunctionUrlConfig orphan cleanup and attribute switching - Remove FunctionUrlConfig from template when [FunctionUrl] attribute is removed - Clean transition when switching from [FunctionUrl] to [HttpApi] or [RestApi] - 4 new unit tests for orphan cleanup and attribute switching scenarios --- .../Writers/CloudFormationWriter.cs | 8 ++ .../WriterTests/FunctionUrlTests.cs | 93 ++++++++++++++----- 2 files changed, 78 insertions(+), 23 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs index 15cb56a13..ce41bc135 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs @@ -203,6 +203,7 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la { var currentSyncedEvents = new List(); var currentSyncedEventProperties = new Dictionary>(); + var hasFunctionUrl = false; foreach (var attributeModel in lambdaFunction.Attributes) { @@ -223,10 +224,17 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la break; case AttributeModel functionUrlAttributeModel: ProcessFunctionUrlAttribute(lambdaFunction, functionUrlAttributeModel.Data); + hasFunctionUrl = true; break; } } + // Remove FunctionUrlConfig if the attribute was removed + if (!hasFunctionUrl) + { + _templateWriter.RemoveToken($"Resources.{lambdaFunction.ResourceName}.Properties.FunctionUrlConfig"); + } + SynchronizeEventsAndProperties(currentSyncedEvents, currentSyncedEventProperties, lambdaFunction); } diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs index ebdafc679..fcdeec6fb 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs @@ -15,25 +15,19 @@ public partial class CloudFormationWriterTests [InlineData(CloudFormationTemplateFormat.Yaml)] public void FunctionUrlWithDefaultAuthType(CloudFormationTemplateFormat templateFormat) { - // ARRANGE var mockFileManager = GetMockFileManager(string.Empty); var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", "TestMethod", 30, 512, null, null); lambdaFunctionModel.Attributes = new List { - new AttributeModel - { - Data = new FunctionUrlAttribute() - } + new AttributeModel { Data = new FunctionUrlAttribute() } }; var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); var report = GetAnnotationReport(new List { lambdaFunctionModel }); ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); - // ACT cloudFormationWriter.ApplyReport(report); - // ASSERT templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); Assert.Equal("NONE", templateWriter.GetToken("Resources.TestMethod.Properties.FunctionUrlConfig.AuthType")); } @@ -43,7 +37,6 @@ public void FunctionUrlWithDefaultAuthType(CloudFormationTemplateFormat template [InlineData(CloudFormationTemplateFormat.Yaml)] public void FunctionUrlWithIamAuth(CloudFormationTemplateFormat templateFormat) { - // ARRANGE var mockFileManager = GetMockFileManager(string.Empty); var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", "TestMethod", 30, 512, null, null); @@ -58,10 +51,8 @@ public void FunctionUrlWithIamAuth(CloudFormationTemplateFormat templateFormat) var report = GetAnnotationReport(new List { lambdaFunctionModel }); ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); - // ACT cloudFormationWriter.ApplyReport(report); - // ASSERT templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); Assert.Equal("AWS_IAM", templateWriter.GetToken("Resources.TestMethod.Properties.FunctionUrlConfig.AuthType")); } @@ -71,25 +62,19 @@ public void FunctionUrlWithIamAuth(CloudFormationTemplateFormat templateFormat) [InlineData(CloudFormationTemplateFormat.Yaml)] public void FunctionUrlDoesNotCreateEventEntry(CloudFormationTemplateFormat templateFormat) { - // ARRANGE - FunctionUrl should NOT create an Events entry (unlike HttpApi/RestApi) var mockFileManager = GetMockFileManager(string.Empty); var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", "TestMethod", 30, 512, null, null); lambdaFunctionModel.Attributes = new List { - new AttributeModel - { - Data = new FunctionUrlAttribute() - } + new AttributeModel { Data = new FunctionUrlAttribute() } }; var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); var report = GetAnnotationReport(new List { lambdaFunctionModel }); ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); - // ACT cloudFormationWriter.ApplyReport(report); - // ASSERT templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.Events")); @@ -100,7 +85,6 @@ public void FunctionUrlDoesNotCreateEventEntry(CloudFormationTemplateFormat temp [InlineData(CloudFormationTemplateFormat.Yaml)] public void FunctionUrlWithCors(CloudFormationTemplateFormat templateFormat) { - // ARRANGE var mockFileManager = GetMockFileManager(string.Empty); var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", "TestMethod", 30, 512, null, null); @@ -123,10 +107,8 @@ public void FunctionUrlWithCors(CloudFormationTemplateFormat templateFormat) var report = GetAnnotationReport(new List { lambdaFunctionModel }); ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); - // ACT cloudFormationWriter.ApplyReport(report); - // ASSERT templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); var corsPath = "Resources.TestMethod.Properties.FunctionUrlConfig.Cors"; Assert.Equal(new List { "https://example.com" }, templateWriter.GetToken>($"{corsPath}.AllowOrigins")); @@ -141,7 +123,6 @@ public void FunctionUrlWithCors(CloudFormationTemplateFormat templateFormat) [InlineData(CloudFormationTemplateFormat.Yaml)] public void FunctionUrlWithoutCorsDoesNotEmitCorsBlock(CloudFormationTemplateFormat templateFormat) { - // ARRANGE - No CORS properties set, so no Cors block should be emitted var mockFileManager = GetMockFileManager(string.Empty); var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", "TestMethod", 30, 512, null, null); @@ -156,13 +137,79 @@ public void FunctionUrlWithoutCorsDoesNotEmitCorsBlock(CloudFormationTemplateFor var report = GetAnnotationReport(new List { lambdaFunctionModel }); ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); - // ACT cloudFormationWriter.ApplyReport(report); - // ASSERT templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig.AuthType")); Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig.Cors")); } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlConfigRemovedWhenAttributeRemoved(CloudFormationTemplateFormat templateFormat) + { + // First pass: create FunctionUrlConfig + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute { AllowOrigins = new[] { "*" } } + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + + // Second pass: remove the attribute, FunctionUrlConfig should be cleaned up + lambdaFunctionModel.Attributes = new List(); + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void SwitchFromFunctionUrlToHttpApi(CloudFormationTemplateFormat templateFormat) + { + // First pass: FunctionUrl + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel { Data = new FunctionUrlAttribute() } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + + // Second pass: switch to HttpApi + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new HttpApiAttribute(LambdaHttpMethod.Get, "/items") + } + }; + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.Events.RootGet")); + Assert.Equal("HttpApi", templateWriter.GetToken("Resources.TestMethod.Properties.Events.RootGet.Type")); + } } } From 6d32835b5afcb1616d416dd65e484dddde1924ef Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Thu, 2 Apr 2026 21:07:45 +0000 Subject: [PATCH 4/8] Phase 4: End-to-end source generator test for FunctionUrl - FunctionUrlExample.cs test source with [FunctionUrl] + [FromQuery] + IHttpResult - Generated wrapper snapshot using HttpApi V2 payload format - Serverless template snapshot with FunctionUrlConfig - Full Roslyn source generator verification test --- .../Amazon.Lambda.RuntimeSupport.csproj | 2 +- .../SnapshotRestore.Registry.csproj | 2 +- ...FunctionUrlExample_GetItems_Generated.g.cs | 98 +++++++++++++++++++ .../functionUrlExample.template | 27 +++++ .../SourceGeneratorTests.cs | 38 +++++++ .../TestServerlessApp/FunctionUrlExample.cs | 17 ++++ 6 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/FunctionUrlExample_GetItems_Generated.g.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/functionUrlExample.template create mode 100644 Libraries/test/TestServerlessApp/FunctionUrlExample.cs diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Amazon.Lambda.RuntimeSupport.csproj b/Libraries/src/Amazon.Lambda.RuntimeSupport/Amazon.Lambda.RuntimeSupport.csproj index 95c92dafb..b3bfb0488 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 + netstandard2.0;net6.0;net8.0;net9.0;net10.0;net11.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 72aa0ef6b..0bb3f5886 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 + net8.0;net9.0;net10.0;net11.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/Snapshots/FunctionUrlExample_GetItems_Generated.g.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/FunctionUrlExample_GetItems_Generated.g.cs new file mode 100644 index 000000000..002cbfe60 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/FunctionUrlExample_GetItems_Generated.g.cs @@ -0,0 +1,98 @@ +// + +using System; +using System.Linq; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using System.IO; +using Amazon.Lambda.Core; +using Amazon.Lambda.Annotations.APIGateway; + +namespace TestServerlessApp +{ + public class FunctionUrlExample_GetItems_Generated + { + private readonly FunctionUrlExample functionUrlExample; + private readonly Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer serializer; + + /// + /// Default constructor. This constructor is used by Lambda to construct the instance. When invoked in a Lambda environment + /// the AWS credentials will come from the IAM role associated with the function and the AWS region will be set to the + /// region the Lambda function is executed in. + /// + public FunctionUrlExample_GetItems_Generated() + { + SetExecutionEnvironment(); + functionUrlExample = new FunctionUrlExample(); + serializer = new Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer(); + } + + /// + /// The generated Lambda function handler for + /// + /// The Function URL request object that will be processed by the Lambda function handler. + /// The ILambdaContext that provides methods for logging and describing the Lambda environment. + /// Result of the Lambda function execution + public System.IO.Stream GetItems(Amazon.Lambda.APIGatewayEvents.APIGatewayHttpApiV2ProxyRequest __request__, Amazon.Lambda.Core.ILambdaContext __context__) + { + var validationErrors = new List(); + + var category = default(string); + if (__request__.QueryStringParameters?.ContainsKey("category") == true) + { + try + { + category = (string)Convert.ChangeType(__request__.QueryStringParameters["category"], typeof(string)); + } + catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException) + { + validationErrors.Add($"Value {__request__.QueryStringParameters["category"]} at 'category' failed to satisfy constraint: {e.Message}"); + } + } + + // return 400 Bad Request if there exists a validation error + if (validationErrors.Any()) + { + var errorResult = new Amazon.Lambda.APIGatewayEvents.APIGatewayHttpApiV2ProxyResponse + { + Body = @$"{{""message"": ""{validationErrors.Count} validation error(s) detected: {string.Join(",", validationErrors)}""}}", + Headers = new Dictionary + { + {"Content-Type", "application/json"}, + {"x-amzn-ErrorType", "ValidationException"} + }, + StatusCode = 400 + }; + var errorStream = new System.IO.MemoryStream(); + serializer.Serialize(errorResult, errorStream); + errorStream.Position = 0; + return errorStream; + } + + var httpResults = functionUrlExample.GetItems(category, __context__); + HttpResultSerializationOptions.ProtocolFormat serializationFormat = HttpResultSerializationOptions.ProtocolFormat.HttpApi; + HttpResultSerializationOptions.ProtocolVersion serializationVersion = HttpResultSerializationOptions.ProtocolVersion.V2; + var serializationOptions = new HttpResultSerializationOptions { Format = serializationFormat, Version = serializationVersion, Serializer = serializer }; + var response = httpResults.Serialize(serializationOptions); + return response; + } + + private static void SetExecutionEnvironment() + { + const string envName = "AWS_EXECUTION_ENV"; + + var envValue = new StringBuilder(); + + // If there is an existing execution environment variable add the annotations package as a suffix. + if(!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(envName))) + { + envValue.Append($"{Environment.GetEnvironmentVariable(envName)}_"); + } + + envValue.Append("lib/amazon-lambda-annotations#{ANNOTATIONS_ASSEMBLY_VERSION}"); + + Environment.SetEnvironmentVariable(envName, envValue.ToString()); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/functionUrlExample.template b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/functionUrlExample.template new file mode 100644 index 000000000..410e95ede --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/functionUrlExample.template @@ -0,0 +1,27 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::Serverless-2016-10-31", + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v{ANNOTATIONS_ASSEMBLY_VERSION}).", + "Resources": { + "TestServerlessAppFunctionUrlExampleGetItemsGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "Runtime": "dotnet6", + "CodeUri": ".", + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Zip", + "Handler": "TestProject::TestServerlessApp.FunctionUrlExample_GetItems_Generated::GetItems", + "FunctionUrlConfig": { + "AuthType": "NONE" + } + } + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs index 63e746059..52e557d8e 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs @@ -1868,6 +1868,44 @@ public async Task IAuthorizerResultHttpApiTest() Assert.Equal(expectedTemplateContent, actualTemplateContent); } + [Fact] + public async Task FunctionUrlExample() + { + var expectedTemplateContent = await ReadSnapshotContent(Path.Combine("Snapshots", "ServerlessTemplates", "functionUrlExample.template")); + var expectedGetItemsGenerated = await ReadSnapshotContent(Path.Combine("Snapshots", "FunctionUrlExample_GetItems_Generated.g.cs")); + + await new VerifyCS.Test + { + TestState = + { + Sources = + { + (Path.Combine("TestServerlessApp", "FunctionUrlExample.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "FunctionUrlExample.cs"))), + (Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"))), + (Path.Combine("Amazon.Lambda.Annotations", "LambdaStartupAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "LambdaStartupAttribute.cs"))), + (Path.Combine("Amazon.Lambda.Annotations", "APIGateway", "FunctionUrlAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "APIGateway", "FunctionUrlAttribute.cs"))), + (Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"))), + }, + GeneratedSources = + { + ( + typeof(SourceGenerator.Generator), + "FunctionUrlExample_GetItems_Generated.g.cs", + SourceText.From(expectedGetItemsGenerated, Encoding.UTF8, SourceHashAlgorithm.Sha256) + ) + }, + ExpectedDiagnostics = + { + new DiagnosticResult("AWSLambda0103", DiagnosticSeverity.Info).WithArguments("FunctionUrlExample_GetItems_Generated.g.cs", expectedGetItemsGenerated), + new DiagnosticResult("AWSLambda0103", DiagnosticSeverity.Info).WithArguments($"TestServerlessApp{Path.DirectorySeparatorChar}serverless.template", expectedTemplateContent) + } + } + }.RunAsync(); + + var actualTemplateContent = await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "serverless.template")); + Assert.Equal(expectedTemplateContent, actualTemplateContent); + } + public void Dispose() { File.Delete(Path.Combine("TestServerlessApp", "serverless.template")); diff --git a/Libraries/test/TestServerlessApp/FunctionUrlExample.cs b/Libraries/test/TestServerlessApp/FunctionUrlExample.cs new file mode 100644 index 000000000..249614a96 --- /dev/null +++ b/Libraries/test/TestServerlessApp/FunctionUrlExample.cs @@ -0,0 +1,17 @@ +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.Core; + +namespace TestServerlessApp +{ + public class FunctionUrlExample + { + [LambdaFunction] + [FunctionUrl(AuthType = FunctionUrlAuthType.NONE)] + public IHttpResult GetItems([FromQuery] string category, ILambdaContext context) + { + context.Logger.LogLine($"Getting items for category: {category}"); + return HttpResults.Ok(new { items = new[] { "item1", "item2" }, category }); + } + } +} From bbcc0ccf2daec8110eb849543228499f09c16768 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Mon, 6 Apr 2026 12:53:49 -0400 Subject: [PATCH 5/8] IT tests --- .../functionUrlExample.template | 11 +- .../IntegrationTests.Helpers/LambdaHelper.cs | 8 ++ .../FunctionUrlExample.cs | 100 ++++++++++++++++++ .../IntegrationTestContextFixture.cs | 13 ++- .../TestServerlessApp/FunctionUrlExample.cs | 2 +- .../aws-lambda-tools-defaults.json | 8 +- .../TestServerlessApp/serverless.template | 23 ++++ 7 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 Libraries/test/TestServerlessApp.IntegrationTests/FunctionUrlExample.cs diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/functionUrlExample.template b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/functionUrlExample.template index 410e95ede..2dce894c6 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/functionUrlExample.template +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/functionUrlExample.template @@ -9,15 +9,18 @@ "Tool": "Amazon.Lambda.Annotations" }, "Properties": { - "Runtime": "dotnet6", - "CodeUri": ".", "MemorySize": 512, "Timeout": 30, "Policies": [ "AWSLambdaBasicExecutionRole" ], - "PackageType": "Zip", - "Handler": "TestProject::TestServerlessApp.FunctionUrlExample_GetItems_Generated::GetItems", + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestProject::TestServerlessApp.FunctionUrlExample_GetItems_Generated::GetItems" + ] + }, "FunctionUrlConfig": { "AuthType": "NONE" } diff --git a/Libraries/test/IntegrationTests.Helpers/LambdaHelper.cs b/Libraries/test/IntegrationTests.Helpers/LambdaHelper.cs index 6436c7c7b..48caa6c16 100644 --- a/Libraries/test/IntegrationTests.Helpers/LambdaHelper.cs +++ b/Libraries/test/IntegrationTests.Helpers/LambdaHelper.cs @@ -55,6 +55,14 @@ public async Task ListEventSourceMappingsAsync( }); } + public async Task GetFunctionUrlConfigAsync(string functionName) + { + return await _lambdaClient.GetFunctionUrlConfigAsync(new GetFunctionUrlConfigRequest + { + FunctionName = functionName + }); + } + public async Task WaitTillNotPending(List functions) { foreach (var function in functions) diff --git a/Libraries/test/TestServerlessApp.IntegrationTests/FunctionUrlExample.cs b/Libraries/test/TestServerlessApp.IntegrationTests/FunctionUrlExample.cs new file mode 100644 index 000000000..eedea499a --- /dev/null +++ b/Libraries/test/TestServerlessApp.IntegrationTests/FunctionUrlExample.cs @@ -0,0 +1,100 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace TestServerlessApp.IntegrationTests +{ + [Collection("Integration Tests")] + public class FunctionUrlExample + { + private readonly IntegrationTestContextFixture _fixture; + + public FunctionUrlExample(IntegrationTestContextFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task GetItems_WithCategory_ReturnsOkWithItems() + { + Assert.False(string.IsNullOrEmpty(_fixture.FunctionUrlPrefix), "FunctionUrlPrefix should not be empty. The Function URL was not discovered during setup."); + + var response = await GetWithRetryAsync($"{_fixture.FunctionUrlPrefix}?category=electronics"); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + var json = JObject.Parse(content); + + Assert.Equal("electronics", json["category"]?.ToString()); + Assert.NotNull(json["items"]); + var items = json["items"].ToObject(); + Assert.Equal(2, items.Length); + Assert.Contains("item1", items); + Assert.Contains("item2", items); + } + + [Fact] + public async Task GetItems_LogsToCloudWatch() + { + Assert.False(string.IsNullOrEmpty(_fixture.FunctionUrlPrefix), "FunctionUrlPrefix should not be empty. The Function URL was not discovered during setup."); + + var response = await GetWithRetryAsync($"{_fixture.FunctionUrlPrefix}?category=books"); + response.EnsureSuccessStatusCode(); + + var lambdaFunctionName = _fixture.LambdaFunctions + .FirstOrDefault(x => string.Equals(x.LogicalId, "TestServerlessAppFunctionUrlExampleGetItemsGenerated"))?.Name; + Assert.False(string.IsNullOrEmpty(lambdaFunctionName)); + + var logGroupName = _fixture.CloudWatchHelper.GetLogGroupName(lambdaFunctionName); + Assert.True( + await _fixture.CloudWatchHelper.MessageExistsInRecentLogEventsAsync("Getting items for category: books", logGroupName, logGroupName), + "Expected log message not found in CloudWatch logs"); + } + + [Fact] + public async Task VerifyFunctionUrlConfig_HasNoneAuthType() + { + var lambdaFunctionName = _fixture.LambdaFunctions + .FirstOrDefault(x => string.Equals(x.LogicalId, "TestServerlessAppFunctionUrlExampleGetItemsGenerated"))?.Name; + Assert.False(string.IsNullOrEmpty(lambdaFunctionName)); + + var functionUrlConfig = await _fixture.LambdaHelper.GetFunctionUrlConfigAsync(lambdaFunctionName); + Assert.NotNull(functionUrlConfig); + Assert.Equal("NONE", functionUrlConfig.AuthType.Value); + Assert.False(string.IsNullOrEmpty(functionUrlConfig.FunctionUrl), "Function URL should not be empty"); + Assert.Contains(".lambda-url.", functionUrlConfig.FunctionUrl); + } + + private async Task GetWithRetryAsync(string url) + { + const int maxAttempts = 10; + HttpResponseMessage response = null; + + for (var attempt = 0; attempt < maxAttempts; attempt++) + { + await Task.Delay(attempt * 1000); + try + { + response = await _fixture.HttpClient.GetAsync(url); + + // If we get a 403 Forbidden, it may be an eventual consistency issue + // with the Function URL permissions propagating. + if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) + continue; + + break; + } + catch + { + if (attempt + 1 == maxAttempts) + throw; + } + } + + return response; + } + } +} diff --git a/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs b/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs index fb689cd7c..e34126d99 100644 --- a/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs +++ b/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs @@ -29,6 +29,7 @@ public class IntegrationTestContextFixture : IAsyncLifetime public string RestApiUrlPrefix; public string HttpApiUrlPrefix; + public string FunctionUrlPrefix; public string TestQueueARN; public List LambdaFunctions; @@ -81,12 +82,22 @@ public async Task InitializeAsync() Console.WriteLine($"[IntegrationTest] Found {LambdaFunctions.Count} Lambda functions: {string.Join(", ", LambdaFunctions.Select(f => f.Name ?? "(null)"))}"); Assert.True(await _s3Helper.BucketExistsAsync(_bucketName), $"S3 bucket {_bucketName} should exist"); - Assert.Equal(36, LambdaFunctions.Count); + Assert.Equal(37, LambdaFunctions.Count); Assert.False(string.IsNullOrEmpty(RestApiUrlPrefix), "RestApiUrlPrefix should not be empty"); Assert.False(string.IsNullOrEmpty(HttpApiUrlPrefix), "HttpApiUrlPrefix should not be empty"); await LambdaHelper.WaitTillNotPending(LambdaFunctions.Where(x => x.Name != null).Select(x => x.Name).ToList()); + // Discover the Function URL for the FunctionUrlExample function + var functionUrlLambdaName = LambdaFunctions + .FirstOrDefault(x => string.Equals(x.LogicalId, "TestServerlessAppFunctionUrlExampleGetItemsGenerated"))?.Name; + if (!string.IsNullOrEmpty(functionUrlLambdaName)) + { + var functionUrlConfig = await LambdaHelper.GetFunctionUrlConfigAsync(functionUrlLambdaName); + FunctionUrlPrefix = functionUrlConfig.FunctionUrl.TrimEnd('/'); + Console.WriteLine($"[IntegrationTest] FunctionUrlPrefix: {FunctionUrlPrefix}"); + } + // Wait an additional 10 seconds for any other eventually consistency state to finish up. await Task.Delay(10000); } diff --git a/Libraries/test/TestServerlessApp/FunctionUrlExample.cs b/Libraries/test/TestServerlessApp/FunctionUrlExample.cs index 249614a96..ebe075052 100644 --- a/Libraries/test/TestServerlessApp/FunctionUrlExample.cs +++ b/Libraries/test/TestServerlessApp/FunctionUrlExample.cs @@ -6,7 +6,7 @@ namespace TestServerlessApp { public class FunctionUrlExample { - [LambdaFunction] + [LambdaFunction(PackageType = LambdaPackageType.Image)] [FunctionUrl(AuthType = FunctionUrlAuthType.NONE)] public IHttpResult GetItems([FromQuery] string category, ILambdaContext context) { diff --git a/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json b/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json index 0b96350ff..71f6d708b 100644 --- a/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json +++ b/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json @@ -13,7 +13,7 @@ "template": "serverless.template", "template-parameters": "", "docker-host-build-output-dir": "./bin/Release/lambda-publish", - "s3-bucket": "test-serverless-app", - "stack-name": "test-serverless-app", - "function-architecture": "x86_64" -} \ No newline at end of file +"s3-bucket" : "test-serverless-app-535afbc5", +"stack-name" : "test-serverless-app-535afbc5", +"function-architecture" : "x86_64" +} diff --git a/Libraries/test/TestServerlessApp/serverless.template b/Libraries/test/TestServerlessApp/serverless.template index a0bf929eb..c9499d8fc 100644 --- a/Libraries/test/TestServerlessApp/serverless.template +++ b/Libraries/test/TestServerlessApp/serverless.template @@ -1339,6 +1339,29 @@ ] } } + }, + "TestServerlessAppFunctionUrlExampleGetItemsGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "FunctionUrlConfig": { + "AuthType": "NONE" + }, + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.FunctionUrlExample_GetItems_Generated::GetItems" + ] + } + } } } } \ No newline at end of file From ba8aadbde5ba02ff4023b48bcecb1c2877813287 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Mon, 6 Apr 2026 13:58:40 -0400 Subject: [PATCH 6/8] Update Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Models/Attributes/FunctionUrlAttributeBuilder.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs index c223beee2..7882d9b43 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using System.Linq; using Amazon.Lambda.Annotations.APIGateway; using Microsoft.CodeAnalysis; From 8830279c3b02197a7c987655c6034cadca1fe323 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Mon, 6 Apr 2026 14:00:29 -0400 Subject: [PATCH 7/8] copilot comments --- .../Writers/CloudFormationWriter.cs | 7 +- .../WriterTests/FunctionUrlTests.cs | 106 ++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs index ce41bc135..60329d45a 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs @@ -311,6 +311,11 @@ private void ProcessFunctionUrlAttribute(ILambdaFunctionSerializable lambdaFunct var functionUrlConfigPath = $"Resources.{lambdaFunction.ResourceName}.Properties.FunctionUrlConfig"; _templateWriter.SetToken($"{functionUrlConfigPath}.AuthType", functionUrlAttribute.AuthType.ToString()); + // Always remove the existing Cors block first to clear any stale properties + // from a previous generation pass, then re-emit only the currently configured values. + var corsPath = $"{functionUrlConfigPath}.Cors"; + _templateWriter.RemoveToken(corsPath); + var hasCors = functionUrlAttribute.AllowOrigins != null || functionUrlAttribute.AllowMethods != null || functionUrlAttribute.AllowHeaders != null @@ -320,8 +325,6 @@ private void ProcessFunctionUrlAttribute(ILambdaFunctionSerializable lambdaFunct if (hasCors) { - var corsPath = $"{functionUrlConfigPath}.Cors"; - if (functionUrlAttribute.AllowOrigins != null) _templateWriter.SetToken($"{corsPath}.AllowOrigins", new List(functionUrlAttribute.AllowOrigins), TokenType.List); diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs index fcdeec6fb..77dc458a4 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs @@ -144,6 +144,112 @@ public void FunctionUrlWithoutCorsDoesNotEmitCorsBlock(CloudFormationTemplateFor Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig.Cors")); } + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlCorsRemovedWhenCorsCleared(CloudFormationTemplateFormat templateFormat) + { + // First pass: emit full CORS config + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute + { + AuthType = FunctionUrlAuthType.NONE, + AllowOrigins = new[] { "https://example.com" }, + AllowMethods = new[] { "GET", "POST" }, + AllowHeaders = new[] { "Content-Type" }, + AllowCredentials = true, + MaxAge = 3600 + } + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + var corsPath = "Resources.TestMethod.Properties.FunctionUrlConfig.Cors"; + Assert.True(templateWriter.Exists(corsPath)); + Assert.Equal(new List { "https://example.com" }, templateWriter.GetToken>($"{corsPath}.AllowOrigins")); + Assert.True(templateWriter.GetToken($"{corsPath}.AllowCredentials")); + Assert.Equal(3600, templateWriter.GetToken($"{corsPath}.MaxAge")); + + // Second pass: clear all CORS properties (AllowOrigins=null, AllowCredentials=false, MaxAge=0) + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute { AuthType = FunctionUrlAuthType.NONE } + } + }; + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal("NONE", templateWriter.GetToken("Resources.TestMethod.Properties.FunctionUrlConfig.AuthType")); + Assert.False(templateWriter.Exists(corsPath)); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlCorsUpdatedBetweenPasses(CloudFormationTemplateFormat templateFormat) + { + // First pass: emit CORS with AllowOrigins and AllowMethods + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute + { + AuthType = FunctionUrlAuthType.NONE, + AllowOrigins = new[] { "https://example.com" }, + AllowMethods = new[] { "GET", "POST" }, + AllowCredentials = true, + MaxAge = 3600 + } + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + var corsPath = "Resources.TestMethod.Properties.FunctionUrlConfig.Cors"; + Assert.True(templateWriter.Exists($"{corsPath}.AllowOrigins")); + Assert.True(templateWriter.Exists($"{corsPath}.AllowMethods")); + Assert.True(templateWriter.Exists($"{corsPath}.AllowCredentials")); + Assert.True(templateWriter.Exists($"{corsPath}.MaxAge")); + + // Second pass: change to only AllowOrigins with a different value, remove everything else + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute + { + AuthType = FunctionUrlAuthType.NONE, + AllowOrigins = new[] { "https://other.com" } + } + } + }; + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal(new List { "https://other.com" }, templateWriter.GetToken>($"{corsPath}.AllowOrigins")); + Assert.False(templateWriter.Exists($"{corsPath}.AllowMethods")); + Assert.False(templateWriter.Exists($"{corsPath}.AllowCredentials")); + Assert.False(templateWriter.Exists($"{corsPath}.MaxAge")); + } + [Theory] [InlineData(CloudFormationTemplateFormat.Json)] [InlineData(CloudFormationTemplateFormat.Yaml)] From f38a6b04f451dd6d13f5fa7a897efa41c0d9e61f Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Mon, 6 Apr 2026 14:02:45 -0400 Subject: [PATCH 8/8] change file --- .../changes/function-url-annotations-support.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .autover/changes/function-url-annotations-support.json diff --git a/.autover/changes/function-url-annotations-support.json b/.autover/changes/function-url-annotations-support.json new file mode 100644 index 000000000..24ad4e288 --- /dev/null +++ b/.autover/changes/function-url-annotations-support.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.Annotations", + "Type": "Minor", + "ChangelogMessages": [ + "Added [FunctionUrl] attribute for configuring Lambda functions with Function URL endpoints, including optional CORS support" + ] + } + ] +}