From bed2729b8520b0e2ad3b96d4f9b719380c5a24e6 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Wed, 1 Apr 2026 14:11:52 -0400 Subject: [PATCH 01/30] feat: Add ALBApi attribute definition for Application Load Balancer support Add ALBApiAttribute class that allows users to configure Lambda functions as targets behind an existing Application Load Balancer. The attribute supports: - Literal ARN or @ResourceName template references for the ALB listener - Path pattern conditions with wildcard support - Priority for listener rules (1-50000) - Optional multi-value headers, host header, and HTTP method conditions - Custom CloudFormation resource naming - Built-in validation for all properties Includes 37 unit tests covering construction, defaults, property tracking, and comprehensive validation scenarios. --- .../ALB/ALBApiAttribute.cs | 146 ++++++++ .../ALBApiAttributeTests.cs | 320 ++++++++++++++++++ 2 files changed, 466 insertions(+) create mode 100644 Libraries/src/Amazon.Lambda.Annotations/ALB/ALBApiAttribute.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/ALBApiAttributeTests.cs diff --git a/Libraries/src/Amazon.Lambda.Annotations/ALB/ALBApiAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/ALB/ALBApiAttribute.cs new file mode 100644 index 000000000..336b7ac5e --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/ALB/ALBApiAttribute.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Amazon.Lambda.Annotations.ALB +{ + /// + /// Configures the Lambda function to be called from an Application Load Balancer. + /// The source generator will create the necessary CloudFormation resources + /// (TargetGroup, ListenerRule, Lambda Permission) to wire the Lambda function + /// as a target behind the specified ALB listener. + /// + /// + /// The listener ARN (or template reference), path pattern, and priority are required. + /// See ALB Lambda documentation. + /// + [AttributeUsage(AttributeTargets.Method)] + public class ALBApiAttribute : Attribute + { + // Only allow alphanumeric characters for resource names + private static readonly Regex _resourceNameRegex = new Regex("^[a-zA-Z0-9]+$"); + + /// + /// The ARN of the existing ALB listener, or a "@ResourceName" reference to a + /// listener resource or parameter defined in the CloudFormation template. + /// To reference a resource in the serverless template, prefix the resource name with "@" symbol. + /// + public string ListenerArn { get; set; } + + /// + /// The path pattern condition for the ALB listener rule (e.g., "/api/orders/*"). + /// ALB supports wildcard path patterns using "*" and "?" characters. + /// + public string PathPattern { get; set; } + + /// + /// The priority of the ALB listener rule. Must be between 1 and 50000. + /// Lower numbers are evaluated first. Each rule on a listener must have a unique priority. + /// + public int Priority { get; set; } + + /// + /// Whether multi-value headers are enabled on the ALB target group. Default: false. + /// When true, the Lambda function should use MultiValueHeaders and + /// MultiValueQueryStringParameters on the request and response objects. + /// When false, use Headers and QueryStringParameters instead. + /// + public bool MultiValueHeaders + { + get => multiValueHeaders.GetValueOrDefault(); + set => multiValueHeaders = value; + } + private bool? multiValueHeaders { get; set; } + internal bool IsMultiValueHeadersSet => multiValueHeaders.HasValue; + + /// + /// Optional host header condition for the listener rule (e.g., "api.example.com"). + /// When specified, the rule will only match requests with this host header value. + /// + public string HostHeader { get; set; } + + /// + /// Optional HTTP method condition for the listener rule (e.g., "GET", "POST"). + /// When specified, the rule will only match requests with this HTTP method. + /// Leave null to match all HTTP methods. + /// + public string HttpMethod { get; set; } + + /// + /// The CloudFormation resource name prefix for the generated ALB resources + /// (TargetGroup, ListenerRule, Permission). Defaults to "{LambdaResourceName}ALB". + /// Must only contain alphanumeric characters. + /// + public string ResourceName + { + get => resourceName; + set => resourceName = value; + } + private string resourceName { get; set; } + internal bool IsResourceNameSet => resourceName != null; + + /// + /// Creates an instance of the class. + /// + /// The ARN of the ALB listener, or a "@ResourceName" reference to a template resource. + /// The path pattern condition (e.g., "/api/orders/*"). + /// The listener rule priority (1-50000). + public ALBApiAttribute(string listenerArn, string pathPattern, int priority) + { + ListenerArn = listenerArn; + PathPattern = pathPattern; + Priority = priority; + } + + /// + /// Validates the attribute properties and returns a list of validation error messages. + /// + internal List Validate() + { + var validationErrors = new List(); + + if (string.IsNullOrEmpty(ListenerArn)) + { + validationErrors.Add($"{nameof(ListenerArn)} is required and cannot be empty."); + } + else if (!ListenerArn.StartsWith("@")) + { + // If it's not a template reference, validate it looks like an ARN + if (!ListenerArn.StartsWith("arn:")) + { + validationErrors.Add($"{nameof(ListenerArn)} = {ListenerArn}. It must be a valid ARN (starting with 'arn:') or a template reference (starting with '@')."); + } + } + + if (string.IsNullOrEmpty(PathPattern)) + { + validationErrors.Add($"{nameof(PathPattern)} is required and cannot be empty."); + } + + if (Priority < 1 || Priority > 50000) + { + validationErrors.Add($"{nameof(Priority)} = {Priority}. It must be between 1 and 50000."); + } + + if (IsResourceNameSet && !_resourceNameRegex.IsMatch(ResourceName)) + { + validationErrors.Add($"{nameof(ResourceName)} = {ResourceName}. It must only contain alphanumeric characters and must not be an empty string."); + } + + if (!string.IsNullOrEmpty(HttpMethod)) + { + var validMethods = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS" + }; + if (!validMethods.Contains(HttpMethod)) + { + validationErrors.Add($"{nameof(HttpMethod)} = {HttpMethod}. It must be a valid HTTP method (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS)."); + } + } + + return validationErrors; + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/ALBApiAttributeTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/ALBApiAttributeTests.cs new file mode 100644 index 000000000..21a9a023f --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/ALBApiAttributeTests.cs @@ -0,0 +1,320 @@ +using Amazon.Lambda.Annotations.ALB; +using System.Linq; +using Xunit; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests +{ + public class ALBApiAttributeTests + { + [Fact] + public void Constructor_SetsRequiredProperties() + { + // Arrange & Act + var attr = new ALBApiAttribute( + "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/50dc6c495c0c9188/f2f7dc8efc522ab2", + "/api/orders/*", + 10); + + // Assert + Assert.Equal("arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/50dc6c495c0c9188/f2f7dc8efc522ab2", attr.ListenerArn); + Assert.Equal("/api/orders/*", attr.PathPattern); + Assert.Equal(10, attr.Priority); + } + + [Fact] + public void DefaultValues_AreCorrect() + { + // Arrange & Act + var attr = new ALBApiAttribute("arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc/def", "/hello", 1); + + // Assert + Assert.False(attr.MultiValueHeaders); + Assert.False(attr.IsMultiValueHeadersSet); + Assert.Null(attr.HostHeader); + Assert.Null(attr.HttpMethod); + Assert.Null(attr.ResourceName); + Assert.False(attr.IsResourceNameSet); + } + + [Fact] + public void MultiValueHeaders_WhenExplicitlySet_IsTracked() + { + var attr = new ALBApiAttribute("arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc/def", "/hello", 1); + + // Before setting + Assert.False(attr.IsMultiValueHeadersSet); + + // After setting to false explicitly + attr.MultiValueHeaders = false; + Assert.True(attr.IsMultiValueHeadersSet); + Assert.False(attr.MultiValueHeaders); + + // After setting to true + attr.MultiValueHeaders = true; + Assert.True(attr.IsMultiValueHeadersSet); + Assert.True(attr.MultiValueHeaders); + } + + [Fact] + public void ResourceName_WhenExplicitlySet_IsTracked() + { + var attr = new ALBApiAttribute("arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc/def", "/hello", 1); + + Assert.False(attr.IsResourceNameSet); + + attr.ResourceName = "MyCustomName"; + Assert.True(attr.IsResourceNameSet); + Assert.Equal("MyCustomName", attr.ResourceName); + } + + [Fact] + public void TemplateReference_IsAccepted() + { + var attr = new ALBApiAttribute("@MyALBListener", "/api/*", 5); + + Assert.Equal("@MyALBListener", attr.ListenerArn); + Assert.StartsWith("@", attr.ListenerArn); + } + + [Fact] + public void OptionalProperties_CanBeSet() + { + var attr = new ALBApiAttribute("@MyALBListener", "/api/*", 5) + { + HostHeader = "api.example.com", + HttpMethod = "GET", + MultiValueHeaders = true, + ResourceName = "MyALBTarget" + }; + + Assert.Equal("api.example.com", attr.HostHeader); + Assert.Equal("GET", attr.HttpMethod); + Assert.True(attr.MultiValueHeaders); + Assert.Equal("MyALBTarget", attr.ResourceName); + } + + // ===== Validation Tests ===== + + [Fact] + public void Validate_ValidArn_ReturnsNoErrors() + { + var attr = new ALBApiAttribute( + "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc/def", + "/api/*", + 1); + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_ValidTemplateReference_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyALBListener", "/api/*", 1); + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_EmptyListenerArn_ReturnsError() + { + var attr = new ALBApiAttribute("", "/api/*", 1); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("ListenerArn", errors[0]); + Assert.Contains("required", errors[0]); + } + + [Fact] + public void Validate_NullListenerArn_ReturnsError() + { + var attr = new ALBApiAttribute(null, "/api/*", 1); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("ListenerArn", errors[0]); + } + + [Fact] + public void Validate_InvalidListenerArn_NotArnOrReference_ReturnsError() + { + var attr = new ALBApiAttribute("some-random-string", "/api/*", 1); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("ListenerArn", errors[0]); + Assert.Contains("arn:", errors[0]); + } + + [Fact] + public void Validate_EmptyPathPattern_ReturnsError() + { + var attr = new ALBApiAttribute("@MyListener", "", 1); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("PathPattern", errors[0]); + Assert.Contains("required", errors[0]); + } + + [Fact] + public void Validate_NullPathPattern_ReturnsError() + { + var attr = new ALBApiAttribute("@MyListener", null, 1); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("PathPattern", errors[0]); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(50001)] + [InlineData(100000)] + public void Validate_InvalidPriority_ReturnsError(int priority) + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", priority); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("Priority", errors[0]); + Assert.Contains("1 and 50000", errors[0]); + } + + [Theory] + [InlineData(1)] + [InlineData(50000)] + [InlineData(100)] + [InlineData(25000)] + public void Validate_ValidPriority_ReturnsNoErrors(int priority) + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", priority); + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_InvalidResourceName_ReturnsError() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + ResourceName = "invalid-name!" + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("ResourceName", errors[0]); + Assert.Contains("alphanumeric", errors[0]); + } + + [Fact] + public void Validate_ValidResourceName_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + ResourceName = "MyValidResource123" + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_UnsetResourceName_ReturnsNoErrors() + { + // ResourceName not set should not produce validation errors + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1); + + var errors = attr.Validate(); + Assert.Empty(errors); + Assert.False(attr.IsResourceNameSet); + } + + [Theory] + [InlineData("GET")] + [InlineData("POST")] + [InlineData("PUT")] + [InlineData("PATCH")] + [InlineData("DELETE")] + [InlineData("HEAD")] + [InlineData("OPTIONS")] + [InlineData("get")] + [InlineData("post")] + public void Validate_ValidHttpMethod_ReturnsNoErrors(string method) + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + HttpMethod = method + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_InvalidHttpMethod_ReturnsError() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + HttpMethod = "INVALID" + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("HttpMethod", errors[0]); + } + + [Fact] + public void Validate_NullHttpMethod_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + HttpMethod = null + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_MultipleErrors_ReturnsAll() + { + var attr = new ALBApiAttribute("", "", 0) + { + ResourceName = "invalid-name!", + HttpMethod = "INVALID" + }; + + var errors = attr.Validate(); + // Should have errors for: ListenerArn, PathPattern, Priority, ResourceName, HttpMethod + Assert.Equal(5, errors.Count); + Assert.Contains(errors, e => e.Contains("ListenerArn")); + Assert.Contains(errors, e => e.Contains("PathPattern")); + Assert.Contains(errors, e => e.Contains("Priority")); + Assert.Contains(errors, e => e.Contains("ResourceName")); + Assert.Contains(errors, e => e.Contains("HttpMethod")); + } + + [Fact] + public void Validate_AllValidWithOptionals_ReturnsNoErrors() + { + var attr = new ALBApiAttribute( + "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc/def", + "/api/v1/products/*", + 42) + { + MultiValueHeaders = true, + HostHeader = "api.example.com", + HttpMethod = "POST", + ResourceName = "ProductsALB" + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + } +} From 28590ffc362659f028af92027763d770e8818516 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Wed, 1 Apr 2026 14:17:53 -0400 Subject: [PATCH 02/30] feat: Add ALB source generator model layer Wire the ALBApiAttribute into the source generator's model infrastructure: - TypeFullNames: Add ALB request/response/attribute constants and ALBRequests set - EventType: Add ALB enum value - EventTypeBuilder: Detect ALBApiAttribute on methods - ALBApiAttributeBuilder: Parse ALBApiAttribute from Roslyn AttributeData - AttributeModelBuilder: Build AttributeModel instances - LambdaMethodModel: Add ReturnsApplicationLoadBalancerResponse helper property - SyntaxReceiver: Register ALBApiAttribute as secondary attribute All 265 existing tests pass. 10 new model-layer unit tests added. --- .../Attributes/ALBApiAttributeBuilder.cs | 48 ++++++ .../Attributes/AttributeModelBuilder.cs | 10 ++ .../Models/EventType.cs | 3 +- .../Models/EventTypeBuilder.cs | 4 + .../Models/LambdaMethodModel.cs | 25 +++ .../SyntaxReceiver.cs | 3 +- .../TypeFullNames.cs | 12 +- .../ALBApiModelTests.cs | 151 ++++++++++++++++++ 8 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBApiAttributeBuilder.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/ALBApiModelTests.cs diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBApiAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBApiAttributeBuilder.cs new file mode 100644 index 000000000..e577e9b3a --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBApiAttributeBuilder.cs @@ -0,0 +1,48 @@ +using Amazon.Lambda.Annotations.ALB; +using Microsoft.CodeAnalysis; +using System; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes +{ + /// + /// Builder for . + /// + public class ALBApiAttributeBuilder + { + public static ALBApiAttribute Build(AttributeData att) + { + if (att.ConstructorArguments.Length != 3) + { + throw new NotSupportedException($"{TypeFullNames.ALBApiAttribute} must have constructor with 3 arguments."); + } + + var listenerArn = att.ConstructorArguments[0].Value as string; + var pathPattern = att.ConstructorArguments[1].Value as string; + var priority = (int)att.ConstructorArguments[2].Value; + + var data = new ALBApiAttribute(listenerArn, pathPattern, priority); + + foreach (var pair in att.NamedArguments) + { + if (pair.Key == nameof(data.MultiValueHeaders) && pair.Value.Value is bool multiValueHeaders) + { + data.MultiValueHeaders = multiValueHeaders; + } + else if (pair.Key == nameof(data.HostHeader) && pair.Value.Value is string hostHeader) + { + data.HostHeader = hostHeader; + } + else if (pair.Key == nameof(data.HttpMethod) && pair.Value.Value is string httpMethod) + { + data.HttpMethod = httpMethod; + } + else if (pair.Key == nameof(data.ResourceName) && pair.Value.Value is string resourceName) + { + data.ResourceName = resourceName; + } + } + + return data; + } + } +} 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..c5366b59c 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs @@ -1,4 +1,5 @@ using System; +using Amazon.Lambda.Annotations.ALB; using Amazon.Lambda.Annotations.APIGateway; using Amazon.Lambda.Annotations.SQS; using Microsoft.CodeAnalysis; @@ -108,6 +109,15 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext Type = TypeModelBuilder.Build(att.AttributeClass, context) }; } + else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.ALBApiAttribute), SymbolEqualityComparer.Default)) + { + var data = ALBApiAttributeBuilder.Build(att); + model = new AttributeModel + { + Data = data, + Type = TypeModelBuilder.Build(att.AttributeClass, context) + }; + } else { model = new AttributeModel diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs index d231967e3..1b392572d 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs @@ -11,6 +11,7 @@ public enum EventType SQS, DynamoDB, Schedule, - Authorizer + Authorizer, + ALB } } \ No newline at end of file diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs index 3f5775851..06a2a0a1c 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs @@ -31,6 +31,10 @@ public static HashSet Build(IMethodSymbol lambdaMethodSymbol, { events.Add(EventType.Authorizer); } + else if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.ALBApiAttribute) + { + events.Add(EventType.ALB); + } } return events; diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/LambdaMethodModel.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/LambdaMethodModel.cs index df80c43e5..601e4d86e 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/LambdaMethodModel.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/LambdaMethodModel.cs @@ -89,6 +89,31 @@ public bool ReturnsIAuthorizerResult } } + /// + /// Returns true if the Lambda function returns either ApplicationLoadBalancerResponse or Task<ApplicationLoadBalancerResponse> + /// + public bool ReturnsApplicationLoadBalancerResponse + { + get + { + if (ReturnsVoid) + { + return false; + } + + if (ReturnType.FullName == TypeFullNames.ApplicationLoadBalancerResponse) + { + return true; + } + if (ReturnsGenericTask && ReturnType.TypeArguments.Count == 1 && ReturnType.TypeArguments[0].FullName == TypeFullNames.ApplicationLoadBalancerResponse) + { + return true; + } + + return false; + } + } + /// /// Returns true if the Lambda function returns either void, Task, SQSBatchResponse or Task /// diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs index a5d7ce9ab..ff6e2ee08 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" }, + { "ALBApiAttribute", "ALBApi" } }; 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..b18ed2842 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs @@ -46,6 +46,10 @@ public static class TypeFullNames public const string SQSBatchResponse = "Amazon.Lambda.SQSEvents.SQSBatchResponse"; public const string SQSEventAttribute = "Amazon.Lambda.Annotations.SQS.SQSEventAttribute"; + public const string ApplicationLoadBalancerRequest = "Amazon.Lambda.ApplicationLoadBalancerEvents.ApplicationLoadBalancerRequest"; + public const string ApplicationLoadBalancerResponse = "Amazon.Lambda.ApplicationLoadBalancerEvents.ApplicationLoadBalancerResponse"; + public const string ALBApiAttribute = "Amazon.Lambda.Annotations.ALB.ALBApiAttribute"; + public const string LambdaSerializerAttribute = "Amazon.Lambda.Core.LambdaSerializerAttribute"; public const string DefaultLambdaSerializer = "Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer"; @@ -63,11 +67,17 @@ public static class TypeFullNames APIGatewayCustomAuthorizerRequest }; + public static HashSet ALBRequests = new HashSet + { + ApplicationLoadBalancerRequest + }; + public static HashSet Events = new HashSet { RestApiAttribute, HttpApiAttribute, - SQSEventAttribute + SQSEventAttribute, + ALBApiAttribute }; } } \ No newline at end of file diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/ALBApiModelTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/ALBApiModelTests.cs new file mode 100644 index 000000000..47f6f4d55 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/ALBApiModelTests.cs @@ -0,0 +1,151 @@ +using Amazon.Lambda.Annotations.ALB; +using Amazon.Lambda.Annotations.SourceGenerator; +using Amazon.Lambda.Annotations.SourceGenerator.Models; +using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests +{ + public class ALBApiModelTests + { + [Fact] + public void TypeFullNames_ContainsALBConstants() + { + Assert.Equal("Amazon.Lambda.ApplicationLoadBalancerEvents.ApplicationLoadBalancerRequest", TypeFullNames.ApplicationLoadBalancerRequest); + Assert.Equal("Amazon.Lambda.ApplicationLoadBalancerEvents.ApplicationLoadBalancerResponse", TypeFullNames.ApplicationLoadBalancerResponse); + Assert.Equal("Amazon.Lambda.Annotations.ALB.ALBApiAttribute", TypeFullNames.ALBApiAttribute); + } + + [Fact] + public void TypeFullNames_Events_ContainsALBApiAttribute() + { + Assert.Contains(TypeFullNames.ALBApiAttribute, TypeFullNames.Events); + } + + [Fact] + public void TypeFullNames_ALBRequests_ContainsLoadBalancerRequest() + { + Assert.Contains(TypeFullNames.ApplicationLoadBalancerRequest, TypeFullNames.ALBRequests); + Assert.Single(TypeFullNames.ALBRequests); + } + + [Fact] + public void EventType_HasALBValue() + { + // Verify the ALB enum value exists + var albEvent = EventType.ALB; + Assert.Equal(EventType.ALB, albEvent); + + // Verify it's distinct from other event types + Assert.NotEqual(EventType.API, albEvent); + Assert.NotEqual(EventType.SQS, albEvent); + } + + [Fact] + public void ALBApiAttributeBuilder_BuildsFromConstructorArgs() + { + // This tests the attribute builder by constructing an ALBApiAttribute directly + // (since we can't easily mock Roslyn AttributeData in unit tests, we test the attribute itself) + var attr = new ALBApiAttribute("@MyListener", "/api/*", 5); + + Assert.Equal("@MyListener", attr.ListenerArn); + Assert.Equal("/api/*", attr.PathPattern); + Assert.Equal(5, attr.Priority); + } + + [Fact] + public void ALBApiAttributeBuilder_BuildsWithAllOptionalProperties() + { + var attr = new ALBApiAttribute("arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc/def", "/api/v1/*", 10) + { + MultiValueHeaders = true, + HostHeader = "api.example.com", + HttpMethod = "POST", + ResourceName = "MyCustomALB" + }; + + Assert.Equal("arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc/def", attr.ListenerArn); + Assert.Equal("/api/v1/*", attr.PathPattern); + Assert.Equal(10, attr.Priority); + Assert.True(attr.MultiValueHeaders); + Assert.True(attr.IsMultiValueHeadersSet); + Assert.Equal("api.example.com", attr.HostHeader); + Assert.Equal("POST", attr.HttpMethod); + Assert.Equal("MyCustomALB", attr.ResourceName); + Assert.True(attr.IsResourceNameSet); + } + + [Fact] + public void LambdaMethodModel_ReturnsApplicationLoadBalancerResponse_WhenDirectReturn() + { + var model = new LambdaMethodModel + { + ReturnsVoid = false, + ReturnsGenericTask = false, + ReturnType = new TypeModel + { + FullName = TypeFullNames.ApplicationLoadBalancerResponse, + TypeArguments = new List() + } + }; + + Assert.True(model.ReturnsApplicationLoadBalancerResponse); + } + + [Fact] + public void LambdaMethodModel_ReturnsApplicationLoadBalancerResponse_WhenTaskReturn() + { + var model = new LambdaMethodModel + { + ReturnsVoid = false, + ReturnsGenericTask = true, + ReturnType = new TypeModel + { + FullName = "System.Threading.Tasks.Task`1", + TypeArguments = new List + { + new TypeModel { FullName = TypeFullNames.ApplicationLoadBalancerResponse } + } + } + }; + + Assert.True(model.ReturnsApplicationLoadBalancerResponse); + } + + [Fact] + public void LambdaMethodModel_ReturnsApplicationLoadBalancerResponse_FalseWhenVoid() + { + var model = new LambdaMethodModel + { + ReturnsVoid = true, + ReturnsGenericTask = false, + ReturnType = new TypeModel + { + FullName = "void", + TypeArguments = new List() + } + }; + + Assert.False(model.ReturnsApplicationLoadBalancerResponse); + } + + [Fact] + public void LambdaMethodModel_ReturnsApplicationLoadBalancerResponse_FalseWhenDifferentType() + { + var model = new LambdaMethodModel + { + ReturnsVoid = false, + ReturnsGenericTask = false, + ReturnType = new TypeModel + { + FullName = "System.String", + TypeArguments = new List() + } + }; + + Assert.False(model.ReturnsApplicationLoadBalancerResponse); + } + } +} From 688dacad8416ce1ce54c4bbd6b458d7d3c54673c Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Wed, 1 Apr 2026 14:20:43 -0400 Subject: [PATCH 03/30] feat: Add ALB code generation support and dependency validation - GeneratedMethodModelBuilder: Handle ALB response type - when the method has [ALBApi], the generated wrapper returns ApplicationLoadBalancerResponse. If the user already returns ApplicationLoadBalancerResponse directly, the type is passed through unchanged. - LambdaFunctionValidator: Add dependency check for Amazon.Lambda.ApplicationLoadBalancerEvents when [ALBApi] is detected. ALB functions use the pass-through (NoEventMethodBody) path in the T4 templates, same as SQS - the user works directly with ApplicationLoadBalancerRequest/Response objects. All 265 tests pass. --- .../Models/GeneratedMethodModelBuilder.cs | 14 ++++++++++++++ .../Validation/LambdaFunctionValidator.cs | 10 ++++++++++ 2 files changed, 24 insertions(+) diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs index decb864ee..524779c99 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs @@ -130,6 +130,20 @@ private static TypeModel BuildResponseType(IMethodSymbol lambdaMethodSymbol, throw new ArgumentOutOfRangeException(); } } + else if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.ALBApiAttribute)) + { + // ALB functions return ApplicationLoadBalancerResponse + // If the user already returns ApplicationLoadBalancerResponse, pass through the return type. + // Otherwise, wrap in ApplicationLoadBalancerResponse. + if (lambdaMethodModel.ReturnsApplicationLoadBalancerResponse) + { + return lambdaMethodModel.ReturnType; + } + var symbol = lambdaMethodModel.ReturnsVoidOrGenericTask ? + task.Construct(context.Compilation.GetTypeByMetadataName(TypeFullNames.ApplicationLoadBalancerResponse)): + context.Compilation.GetTypeByMetadataName(TypeFullNames.ApplicationLoadBalancerResponse); + return TypeModelBuilder.Build(symbol, context); + } else { return lambdaMethodModel.ReturnType; diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs index 733124209..8ceb101e9 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs @@ -86,6 +86,16 @@ internal static bool ValidateDependencies(GeneratorExecutionContext context, IMe } } + // Check for references to "Amazon.Lambda.ApplicationLoadBalancerEvents" if the Lambda method is annotated with ALBApi attribute. + if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.ALBApiAttribute)) + { + if (context.Compilation.ReferencedAssemblyNames.FirstOrDefault(x => x.Name == "Amazon.Lambda.ApplicationLoadBalancerEvents") == null) + { + diagnosticReporter.Report(Diagnostic.Create(DiagnosticDescriptors.MissingDependencies, methodLocation, "Amazon.Lambda.ApplicationLoadBalancerEvents")); + return false; + } + } + return true; } From a902df175f6457d8e658a275193ae2cbc171d8f0 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Wed, 1 Apr 2026 14:34:51 -0400 Subject: [PATCH 04/30] feat: Add ALB CloudFormation template generation Add ProcessAlbApiAttribute to CloudFormationWriter that generates three standalone CloudFormation resources for ALB Lambda integration: 1. AWS::Lambda::Permission - allows ELB to invoke the Lambda function 2. AWS::ElasticLoadBalancingV2::TargetGroup - registers Lambda as target with configurable multi-value headers support 3. AWS::ElasticLoadBalancingV2::ListenerRule - routes traffic based on path-pattern, optional host-header and HTTP method conditions Supports both literal ARN and @ResourceName template references for the ALB listener (using Ref for template references). All 265 existing tests pass. --- .../Writers/CloudFormationWriter.cs | 109 +++++++++++++++++- 1 file changed, 107 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 a59aaf6d4..932749780 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs @@ -1,4 +1,5 @@ -using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.Annotations.ALB; +using Amazon.Lambda.Annotations.APIGateway; using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics; using Amazon.Lambda.Annotations.SourceGenerator.FileIO; using Amazon.Lambda.Annotations.SourceGenerator.Models; @@ -221,6 +222,9 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la eventName = ProcessSqsAttribute(lambdaFunction, sqsAttributeModel.Data, currentSyncedEventProperties); currentSyncedEvents.Add(eventName); break; + case AttributeModel albAttributeModel: + ProcessAlbApiAttribute(lambdaFunction, albAttributeModel.Data); + break; } } @@ -597,8 +601,109 @@ private string ProcessSqsAttribute(ILambdaFunctionSerializable lambdaFunction, S } /// - /// Writes all properties associated with to the serverless template. + /// Generates CloudFormation resources for an Application Load Balancer target. + /// Unlike API Gateway events which map to SAM event types, ALB integration requires + /// generating standalone CloudFormation resources: a TargetGroup, a ListenerRule, and a Lambda Permission. /// + private void ProcessAlbApiAttribute(ILambdaFunctionSerializable lambdaFunction, ALBApiAttribute att) + { + var baseName = att.IsResourceNameSet ? att.ResourceName : $"{lambdaFunction.ResourceName}ALB"; + var permissionName = $"{baseName}Permission"; + var targetGroupName = $"{baseName}TargetGroup"; + var listenerRuleName = $"{baseName}ListenerRule"; + + // 1. Lambda Permission - allows ELB to invoke the Lambda function + var permPath = $"Resources.{permissionName}"; + if (!_templateWriter.Exists(permPath) || + string.Equals(_templateWriter.GetToken($"{permPath}.Metadata.Tool", string.Empty), CREATION_TOOL, StringComparison.Ordinal)) + { + _templateWriter.SetToken($"{permPath}.Type", "AWS::Lambda::Permission"); + _templateWriter.SetToken($"{permPath}.Metadata.Tool", CREATION_TOOL); + _templateWriter.SetToken($"{permPath}.Properties.FunctionName.{GET_ATTRIBUTE}", new List { lambdaFunction.ResourceName, "Arn" }, TokenType.List); + _templateWriter.SetToken($"{permPath}.Properties.Action", "lambda:InvokeFunction"); + _templateWriter.SetToken($"{permPath}.Properties.Principal", "elasticloadbalancing.amazonaws.com"); + } + + // 2. Target Group - registers the Lambda function as a target + var tgPath = $"Resources.{targetGroupName}"; + if (!_templateWriter.Exists(tgPath) || + string.Equals(_templateWriter.GetToken($"{tgPath}.Metadata.Tool", string.Empty), CREATION_TOOL, StringComparison.Ordinal)) + { + _templateWriter.SetToken($"{tgPath}.Type", "AWS::ElasticLoadBalancingV2::TargetGroup"); + _templateWriter.SetToken($"{tgPath}.Metadata.Tool", CREATION_TOOL); + _templateWriter.SetToken($"{tgPath}.DependsOn", permissionName); + _templateWriter.SetToken($"{tgPath}.Properties.TargetType", "lambda"); + _templateWriter.SetToken($"{tgPath}.Properties.MultiValueHeadersEnabled", att.MultiValueHeaders); + _templateWriter.SetToken($"{tgPath}.Properties.Targets", new List> + { + new Dictionary + { + { "Id", new Dictionary> { { GET_ATTRIBUTE, new List { lambdaFunction.ResourceName, "Arn" } } } } + } + }, TokenType.List); + } + + // 3. Listener Rule - routes traffic from the ALB listener to the target group + var rulePath = $"Resources.{listenerRuleName}"; + if (!_templateWriter.Exists(rulePath) || + string.Equals(_templateWriter.GetToken($"{rulePath}.Metadata.Tool", string.Empty), CREATION_TOOL, StringComparison.Ordinal)) + { + _templateWriter.SetToken($"{rulePath}.Type", "AWS::ElasticLoadBalancingV2::ListenerRule"); + _templateWriter.SetToken($"{rulePath}.Metadata.Tool", CREATION_TOOL); + + // ListenerArn - handle @reference vs literal ARN + _templateWriter.RemoveToken($"{rulePath}.Properties.ListenerArn"); + if (!string.IsNullOrEmpty(att.ListenerArn) && att.ListenerArn.StartsWith("@")) + { + var refName = att.ListenerArn.Substring(1); + _templateWriter.SetToken($"{rulePath}.Properties.ListenerArn.{REF}", refName); + } + else + { + _templateWriter.SetToken($"{rulePath}.Properties.ListenerArn", att.ListenerArn); + } + + // Priority + _templateWriter.SetToken($"{rulePath}.Properties.Priority", att.Priority); + + // Conditions + var conditions = new List> + { + new Dictionary + { + { "Field", "path-pattern" }, + { "Values", new List { att.PathPattern } } + } + }; + if (!string.IsNullOrEmpty(att.HostHeader)) + { + conditions.Add(new Dictionary + { + { "Field", "host-header" }, + { "Values", new List { att.HostHeader } } + }); + } + if (!string.IsNullOrEmpty(att.HttpMethod)) + { + conditions.Add(new Dictionary + { + { "Field", "http-request-method" }, + { "Values", new List { att.HttpMethod.ToUpper() } } + }); + } + _templateWriter.SetToken($"{rulePath}.Properties.Conditions", conditions, TokenType.List); + + // Actions - forward to target group + _templateWriter.SetToken($"{rulePath}.Properties.Actions", new List> + { + new Dictionary + { + { "Type", "forward" }, + { "TargetGroupArn", new Dictionary { { REF, targetGroupName } } } + } + }, TokenType.List); + } + } /// /// Writes the default values for the Lambda function's metadata and properties. From 64a05ba192af33fe8cdce85435591a0e73de7958 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Wed, 1 Apr 2026 14:38:25 -0400 Subject: [PATCH 05/30] test: Add ALB CloudFormation writer unit tests Add 12 tests (6 scenarios x JSON/YAML) covering: - Basic ALB attribute generates TargetGroup, ListenerRule, and Permission - Template reference (@ResourceName) uses Ref for ListenerArn - MultiValueHeaders flag sets MultiValueHeadersEnabled on TargetGroup - Custom ResourceName uses custom prefix for all 3 resources - HostHeader condition adds host-header to ListenerRule conditions - HttpMethod condition adds http-request-method to ListenerRule conditions All 277 tests pass (265 original + 12 new). --- .../WriterTests/ALBEventsTests.cs | 229 ++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/ALBEventsTests.cs diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/ALBEventsTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/ALBEventsTests.cs new file mode 100644 index 000000000..928e0089d --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/ALBEventsTests.cs @@ -0,0 +1,229 @@ +using Amazon.Lambda.Annotations.ALB; +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 System.Collections.Generic; +using Xunit; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests.WriterTests +{ + public partial class CloudFormationWriterTests + { + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void ALBApiAttribute_GeneratesTargetGroupListenerRuleAndPermission(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "HelloWorld", 30, 256, null, null); + lambdaFunctionModel.PackageType = LambdaPackageType.Zip; + + var albAttribute = new ALBApiAttribute( + "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc/def", + "/api/hello", + 1); + + lambdaFunctionModel.Attributes.Add(new AttributeModel { Data = albAttribute }); + 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)); + + // Verify Lambda function exists + Assert.True(templateWriter.Exists("Resources.HelloWorld")); + Assert.Equal("AWS::Serverless::Function", templateWriter.GetToken("Resources.HelloWorld.Type")); + + // Verify Permission resource + Assert.True(templateWriter.Exists("Resources.HelloWorldALBPermission")); + Assert.Equal("AWS::Lambda::Permission", templateWriter.GetToken("Resources.HelloWorldALBPermission.Type")); + Assert.Equal("Amazon.Lambda.Annotations", templateWriter.GetToken("Resources.HelloWorldALBPermission.Metadata.Tool")); + Assert.Equal("lambda:InvokeFunction", templateWriter.GetToken("Resources.HelloWorldALBPermission.Properties.Action")); + Assert.Equal("elasticloadbalancing.amazonaws.com", templateWriter.GetToken("Resources.HelloWorldALBPermission.Properties.Principal")); + Assert.Equal(new List { "HelloWorld", "Arn" }, + templateWriter.GetToken>("Resources.HelloWorldALBPermission.Properties.FunctionName.Fn::GetAtt")); + + // Verify TargetGroup resource + Assert.True(templateWriter.Exists("Resources.HelloWorldALBTargetGroup")); + Assert.Equal("AWS::ElasticLoadBalancingV2::TargetGroup", templateWriter.GetToken("Resources.HelloWorldALBTargetGroup.Type")); + Assert.Equal("Amazon.Lambda.Annotations", templateWriter.GetToken("Resources.HelloWorldALBTargetGroup.Metadata.Tool")); + Assert.Equal("HelloWorldALBPermission", templateWriter.GetToken("Resources.HelloWorldALBTargetGroup.DependsOn")); + Assert.Equal("lambda", templateWriter.GetToken("Resources.HelloWorldALBTargetGroup.Properties.TargetType")); + Assert.False(templateWriter.GetToken("Resources.HelloWorldALBTargetGroup.Properties.MultiValueHeadersEnabled")); + + // Verify ListenerRule resource + Assert.True(templateWriter.Exists("Resources.HelloWorldALBListenerRule")); + Assert.Equal("AWS::ElasticLoadBalancingV2::ListenerRule", templateWriter.GetToken("Resources.HelloWorldALBListenerRule.Type")); + Assert.Equal("Amazon.Lambda.Annotations", templateWriter.GetToken("Resources.HelloWorldALBListenerRule.Metadata.Tool")); + Assert.Equal("arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc/def", + templateWriter.GetToken("Resources.HelloWorldALBListenerRule.Properties.ListenerArn")); + Assert.Equal(1, templateWriter.GetToken("Resources.HelloWorldALBListenerRule.Properties.Priority")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void ALBApiAttribute_WithTemplateReference_UsesRef(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "MyFunction", 30, 256, null, null); + lambdaFunctionModel.PackageType = LambdaPackageType.Zip; + + var albAttribute = new ALBApiAttribute("@MyALBListener", "/api/*", 5); + lambdaFunctionModel.Attributes.Add(new AttributeModel { Data = albAttribute }); + + 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)); + + // Verify ListenerArn uses Ref + Assert.Equal("MyALBListener", + templateWriter.GetToken("Resources.MyFunctionALBListenerRule.Properties.ListenerArn.Ref")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void ALBApiAttribute_WithMultiValueHeaders_SetsEnabled(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "MyFunction", 30, 256, null, null); + lambdaFunctionModel.PackageType = LambdaPackageType.Zip; + + var albAttribute = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + MultiValueHeaders = true + }; + lambdaFunctionModel.Attributes.Add(new AttributeModel { Data = albAttribute }); + + 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.GetToken("Resources.MyFunctionALBTargetGroup.Properties.MultiValueHeadersEnabled")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void ALBApiAttribute_WithCustomResourceName_UsesCustomPrefix(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "MyFunction", 30, 256, null, null); + lambdaFunctionModel.PackageType = LambdaPackageType.Zip; + + var albAttribute = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + ResourceName = "CustomALB" + }; + lambdaFunctionModel.Attributes.Add(new AttributeModel { Data = albAttribute }); + + 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)); + + // Verify custom resource names are used + Assert.True(templateWriter.Exists("Resources.CustomALBPermission")); + Assert.True(templateWriter.Exists("Resources.CustomALBTargetGroup")); + Assert.True(templateWriter.Exists("Resources.CustomALBListenerRule")); + + // Verify default names are NOT used + Assert.False(templateWriter.Exists("Resources.MyFunctionALBPermission")); + Assert.False(templateWriter.Exists("Resources.MyFunctionALBTargetGroup")); + Assert.False(templateWriter.Exists("Resources.MyFunctionALBListenerRule")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void ALBApiAttribute_WithHostHeaderCondition_AddsCondition(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "MyFunction", 30, 256, null, null); + lambdaFunctionModel.PackageType = LambdaPackageType.Zip; + + var albAttribute = new ALBApiAttribute("@MyListener", "/api/*", 10) + { + HostHeader = "api.example.com" + }; + lambdaFunctionModel.Attributes.Add(new AttributeModel { Data = albAttribute }); + + 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)); + + // Verify conditions exist (path-pattern + host-header = 2 conditions) + Assert.True(templateWriter.Exists("Resources.MyFunctionALBListenerRule.Properties.Conditions")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void ALBApiAttribute_WithHttpMethodCondition_AddsCondition(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "MyFunction", 30, 256, null, null); + lambdaFunctionModel.PackageType = LambdaPackageType.Zip; + + var albAttribute = new ALBApiAttribute("@MyListener", "/api/*", 10) + { + HttpMethod = "POST" + }; + lambdaFunctionModel.Attributes.Add(new AttributeModel { Data = albAttribute }); + + 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)); + + // Verify conditions and priority + Assert.True(templateWriter.Exists("Resources.MyFunctionALBListenerRule.Properties.Conditions")); + Assert.Equal(10, templateWriter.GetToken("Resources.MyFunctionALBListenerRule.Properties.Priority")); + } + } +} From d26a6d3a53d7846c57aa700bb7995700eaf08539 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Wed, 1 Apr 2026 15:36:08 -0400 Subject: [PATCH 06/30] test: Add ALB source generator snapshot tests Add end-to-end source generator test that verifies the full compilation pipeline for ALB-annotated Lambda functions: - ValidALBEvents.cs.txt: Test source with two ALB functions (literal ARN and @template reference with all optional properties) - Snapshot .g.cs files: Expected generated wrapper code for Hello and HandleRequest methods - albEvents.template: Expected CloudFormation template with TargetGroup, ListenerRule, Permission resources for both functions - CSharpSourceGeneratorVerifier: Added ApplicationLoadBalancerEvents assembly reference so the Roslyn test compilation can resolve ALB types All 278 tests pass (277 previous + 1 new snapshot test). --- ....Annotations.SourceGenerators.Tests.csproj | 1 + .../CSharpSourceGeneratorVerifier.cs | 2 + ...alidALBEvents_HandleRequest_Generated.g.cs | 58 ++++++ .../ALB/ValidALBEvents_Hello_Generated.g.cs | 58 ++++++ .../ServerlessTemplates/albEvents.template | 181 ++++++++++++++++++ .../SourceGeneratorTests.cs | 47 +++++ .../ALBEventExamples/ValidALBEvents.cs.txt | 42 ++++ 7 files changed, 389 insertions(+) create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ALB/ValidALBEvents_HandleRequest_Generated.g.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ALB/ValidALBEvents_Hello_Generated.g.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/albEvents.template create mode 100644 Libraries/test/TestServerlessApp/ALBEventExamples/ValidALBEvents.cs.txt diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj index c8cc6f306..b9b6a4113 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj @@ -208,6 +208,7 @@ +