diff --git a/.autover/changes/alb-annotations-support.json b/.autover/changes/alb-annotations-support.json new file mode 100644 index 000000000..d6b2a21e5 --- /dev/null +++ b/.autover/changes/alb-annotations-support.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.Annotations", + "Type": "Minor", + "ChangelogMessages": [ + "Added [ALBApi] attribute for configuring Lambda functions as targets behind an Application Load Balancer" + ] + } + ] +} diff --git a/Libraries/Amazon.Lambda.Annotations.slnf b/Libraries/Amazon.Lambda.Annotations.slnf index ecb4e01ee..d0bf67584 100644 --- a/Libraries/Amazon.Lambda.Annotations.slnf +++ b/Libraries/Amazon.Lambda.Annotations.slnf @@ -16,7 +16,10 @@ "test\\TestCustomAuthorizerApp.IntegrationTests\\TestCustomAuthorizerApp.IntegrationTests.csproj", "test\\TestServerlessApp.IntegrationTests\\TestServerlessApp.IntegrationTests.csproj", "test\\TestServerlessApp.NET8\\TestServerlessApp.NET8.csproj", - "test\\TestServerlessApp\\TestServerlessApp.csproj" + "src\\Amazon.Lambda.ApplicationLoadBalancerEvents\\Amazon.Lambda.ApplicationLoadBalancerEvents.csproj", + "test\\TestServerlessApp\\TestServerlessApp.csproj", + "test\\TestServerlessApp.ALB\\TestServerlessApp.ALB.csproj", + "test\\TestServerlessApp.ALB.IntegrationTests\\TestServerlessApp.ALB.IntegrationTests.csproj" ] } } diff --git a/Libraries/Libraries.sln b/Libraries/Libraries.sln index f3214606a..17e39c553 100644 --- a/Libraries/Libraries.sln +++ b/Libraries/Libraries.sln @@ -151,6 +151,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestCustomAuthorizerApp.Int EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestCustomAuthorizerApp", "test\TestCustomAuthorizerApp\TestCustomAuthorizerApp.csproj", "{3BFA4B73-BA61-4578-833B-C5B3A16EDA9E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestServerlessApp.ALB", "test\TestServerlessApp.ALB\TestServerlessApp.ALB.csproj", "{8F7C617D-C611-4DC6-A07C-033F13C1835D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestServerlessApp.ALB.IntegrationTests", "test\TestServerlessApp.ALB.IntegrationTests\TestServerlessApp.ALB.IntegrationTests.csproj", "{80594C21-C6EB-469E-83CC-68F9F661CA5E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -941,6 +945,30 @@ Global {3BFA4B73-BA61-4578-833B-C5B3A16EDA9E}.Release|x64.Build.0 = Release|Any CPU {3BFA4B73-BA61-4578-833B-C5B3A16EDA9E}.Release|x86.ActiveCfg = Release|Any CPU {3BFA4B73-BA61-4578-833B-C5B3A16EDA9E}.Release|x86.Build.0 = Release|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Debug|x64.ActiveCfg = Debug|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Debug|x64.Build.0 = Debug|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Debug|x86.ActiveCfg = Debug|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Debug|x86.Build.0 = Debug|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Release|Any CPU.Build.0 = Release|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Release|x64.ActiveCfg = Release|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Release|x64.Build.0 = Release|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Release|x86.ActiveCfg = Release|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Release|x86.Build.0 = Release|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Debug|x64.ActiveCfg = Debug|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Debug|x64.Build.0 = Debug|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Debug|x86.ActiveCfg = Debug|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Debug|x86.Build.0 = Debug|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Release|Any CPU.Build.0 = Release|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Release|x64.ActiveCfg = Release|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Release|x64.Build.0 = Release|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Release|x86.ActiveCfg = Release|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1015,6 +1043,8 @@ Global {8D03BDF3-7078-4B46-A3F1-C73BE6D6CE0D} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} {8EEDD576-7FC4-4FAC-A5A2-F58562753A53} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} {3BFA4B73-BA61-4578-833B-C5B3A16EDA9E} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} + {8F7C617D-C611-4DC6-A07C-033F13C1835D} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} + {80594C21-C6EB-469E-83CC-68F9F661CA5E} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {503678A4-B8D1-4486-8915-405A3E9CF0EB} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md index e9b44dd1e..8ff092c83 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md @@ -16,3 +16,7 @@ AWSLambda0128 | AWSLambdaCSharpGenerator | Error | Authorizer Payload Version Mi AWSLambda0129 | AWSLambdaCSharpGenerator | Error | Missing LambdaFunction Attribute AWSLambda0130 | AWSLambdaCSharpGenerator | Error | Invalid return type IAuthorizerResult AWSLambda0131 | AWSLambdaCSharpGenerator | Error | FromBody not supported on Authorizer functions +AWSLambda0132 | AWSLambdaCSharpGenerator | Error | Invalid ALBApiAttribute +AWSLambda0133 | AWSLambdaCSharpGenerator | Error | ALB Listener Reference Not Found +AWSLambda0134 | AWSLambdaCSharpGenerator | Error | FromRoute not supported on ALB functions +AWSLambda0135 | AWSLambdaCSharpGenerator | Error | Unmapped parameter on ALB function diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs index 69c4f9428..aef6767ce 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs @@ -242,5 +242,37 @@ public static class DiagnosticDescriptors category: "AWSLambdaCSharpGenerator", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor InvalidAlbApiAttribute = new DiagnosticDescriptor( + id: "AWSLambda0132", + title: "Invalid ALBApiAttribute", + messageFormat: "Invalid ALBApiAttribute encountered: {0}", + category: "AWSLambdaCSharpGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor AlbListenerReferenceNotFound = new DiagnosticDescriptor( + id: "AWSLambda0133", + title: "ALB Listener Reference Not Found", + messageFormat: "The ALBApi ListenerArn references '@{0}', but no resource or parameter named '{0}' was found in the CloudFormation template. Add the listener resource to the template or correct the reference name.", + category: "AWSLambdaCSharpGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor FromRouteNotSupportedOnAlb = new DiagnosticDescriptor( + id: "AWSLambda0134", + title: "FromRoute not supported on ALB functions", + messageFormat: "[FromRoute] is not supported on ALB functions. ALB does not support route path template parameters. Use [FromHeader], [FromQuery], or [FromBody] instead.", + category: "AWSLambdaCSharpGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor AlbUnmappedParameter = new DiagnosticDescriptor( + id: "AWSLambda0135", + title: "Unmapped parameter on ALB function", + messageFormat: "Parameter '{0}' on ALB function has no binding attribute. Use [FromHeader], [FromQuery], [FromBody], or [FromServices], or use the ApplicationLoadBalancerRequest or ILambdaContext types.", + category: "AWSLambdaCSharpGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true); } } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Extensions/ParameterListExtension.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Extensions/ParameterListExtension.cs index 5465f8323..9310019eb 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Extensions/ParameterListExtension.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Extensions/ParameterListExtension.cs @@ -17,6 +17,12 @@ public static bool HasConvertibleParameter(this IList parameters return false; } + // ALB request types are forwarded to lambda method if specified, there is no parameter conversion required. + if (TypeFullNames.ALBRequests.Contains(p.Type.FullName)) + { + return false; + } + // ILambdaContext is forwarded to lambda method if specified, there is no parameter conversion required. if (p.Type.FullName == TypeFullNames.ILambdaContext) { @@ -24,7 +30,7 @@ public static bool HasConvertibleParameter(this IList parameters } // Body parameter with target type as string doesn't require conversion because body is string by nature. - if (p.Attributes.Any(att => att.Type.FullName == TypeFullNames.FromBodyAttribute) && p.Type.IsString()) + if (p.Attributes.Any(att => att.Type.FullName == TypeFullNames.FromBodyAttribute || att.Type.FullName == TypeFullNames.ALBFromBodyAttribute) && p.Type.IsString()) { return false; } 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..a65670f6e --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBApiAttributeBuilder.cs @@ -0,0 +1,65 @@ +using Amazon.Lambda.Annotations.ALB; +using Microsoft.CodeAnalysis; +using System; +using System.Linq; + +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; + } + else if (pair.Key == nameof(data.HttpHeaderConditionName) && pair.Value.Value is string httpHeaderConditionName) + { + data.HttpHeaderConditionName = httpHeaderConditionName; + } + else if (pair.Key == nameof(data.HttpHeaderConditionValues) && !pair.Value.IsNull) + { + data.HttpHeaderConditionValues = pair.Value.Values.Select(v => v.Value as string).ToArray(); + } + else if (pair.Key == nameof(data.QueryStringConditions) && !pair.Value.IsNull) + { + data.QueryStringConditions = pair.Value.Values.Select(v => v.Value as string).ToArray(); + } + else if (pair.Key == nameof(data.SourceIpConditions) && !pair.Value.IsNull) + { + data.SourceIpConditions = pair.Value.Values.Select(v => v.Value as string).ToArray(); + } + } + + return data; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBFromHeaderAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBFromHeaderAttributeBuilder.cs new file mode 100644 index 000000000..e95b8ef6c --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBFromHeaderAttributeBuilder.cs @@ -0,0 +1,25 @@ +using Amazon.Lambda.Annotations.ALB; +using Microsoft.CodeAnalysis; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes +{ + /// + /// Builder for . + /// + public class ALBFromHeaderAttributeBuilder + { + public static ALB.FromHeaderAttribute Build(AttributeData att) + { + var data = new ALB.FromHeaderAttribute(); + foreach (var pair in att.NamedArguments) + { + if (pair.Key == nameof(data.Name) && pair.Value.Value is string value) + { + data.Name = value; + } + } + + return data; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBFromQueryAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBFromQueryAttributeBuilder.cs new file mode 100644 index 000000000..9b814c2c8 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBFromQueryAttributeBuilder.cs @@ -0,0 +1,25 @@ +using Amazon.Lambda.Annotations.ALB; +using Microsoft.CodeAnalysis; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes +{ + /// + /// Builder for . + /// + public class ALBFromQueryAttributeBuilder + { + public static ALB.FromQueryAttribute Build(AttributeData att) + { + var data = new ALB.FromQueryAttribute(); + foreach (var pair in att.NamedArguments) + { + if (pair.Key == nameof(data.Name) && pair.Value.Value is string value) + { + data.Name = value; + } + } + + 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..add9e6c03 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; @@ -30,7 +31,7 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.FromQueryAttribute), SymbolEqualityComparer.Default)) { var data = FromQueryAttributeBuilder.Build(att); - model = new AttributeModel + model = new AttributeModel { Data = data, Type = TypeModelBuilder.Build(att.AttributeClass, context) @@ -39,7 +40,7 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.FromHeaderAttribute), SymbolEqualityComparer.Default)) { var data = FromHeaderAttributeBuilder.Build(att); - model = new AttributeModel + model = new AttributeModel { Data = data, Type = TypeModelBuilder.Build(att.AttributeClass, context) @@ -108,6 +109,42 @@ 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 if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.ALBFromQueryAttribute), SymbolEqualityComparer.Default)) + { + var data = ALBFromQueryAttributeBuilder.Build(att); + model = new AttributeModel + { + Data = data, + Type = TypeModelBuilder.Build(att.AttributeClass, context) + }; + } + else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.ALBFromHeaderAttribute), SymbolEqualityComparer.Default)) + { + var data = ALBFromHeaderAttributeBuilder.Build(att); + model = new AttributeModel + { + Data = data, + Type = TypeModelBuilder.Build(att.AttributeClass, context) + }; + } + else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.ALBFromBodyAttribute), SymbolEqualityComparer.Default)) + { + var data = new ALB.FromBodyAttribute(); + 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/GeneratedMethodModelBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs index decb864ee..2dcd58fe0 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; @@ -277,6 +291,19 @@ private static IList BuildParameters(IMethodSymbol lambdaMethodS parameters.Add(requestParameter); parameters.Add(contextParameter); } + else if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.ALBApiAttribute)) + { + var symbol = context.Compilation.GetTypeByMetadataName(TypeFullNames.ApplicationLoadBalancerRequest); + var type = TypeModelBuilder.Build(symbol, context); + var requestParameter = new ParameterModel + { + Name = "__request__", + Type = type, + Documentation = "The ALB 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/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/Templates/ALBInvoke.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvoke.cs new file mode 100644 index 000000000..a09bec840 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvoke.cs @@ -0,0 +1,420 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +namespace Amazon.Lambda.Annotations.SourceGenerator.Templates +{ + using System.Linq; + using System.Text; + using System.Collections.Generic; + using Amazon.Lambda.Annotations.SourceGenerator.Extensions; + using Amazon.Lambda.Annotations.SourceGenerator.Models; + using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; + using System; + + /// + /// Class to produce the template output + /// + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "18.0.0.0")] + public partial class ALBInvoke : ALBInvokeBase + { + /// + /// Create the template output + /// + public virtual string TransformText() + { + + if (_model.GeneratedMethod.ReturnType.FullName == _model.LambdaMethod.ReturnType.FullName) + { + // User already returns ApplicationLoadBalancerResponse (or Task), + // just pass through. + if (_model.LambdaMethod.ReturnsVoid) + { + + this.Write(" "); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ContainingType.Name.ToCamelCase())); + this.Write("."); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.Name)); + this.Write("("); + this.Write(this.ToStringHelper.ToStringWithCulture(_parameterSignature)); + this.Write(");\r\n"); + + } + else if (_model.LambdaMethod.ReturnsVoidTask) + { + + this.Write(" await "); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ContainingType.Name.ToCamelCase())); + this.Write("."); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.Name)); + this.Write("("); + this.Write(this.ToStringHelper.ToStringWithCulture(_parameterSignature)); + this.Write(");\r\n"); + + } + else + { + + this.Write(" var response = "); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ReturnsGenericTask ? "await " : "")); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ContainingType.Name.ToCamelCase())); + this.Write("."); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.Name)); + this.Write("("); + this.Write(this.ToStringHelper.ToStringWithCulture(_parameterSignature)); + this.Write(");\r\n return response;\r\n"); + + } + } + else + { + // User returns a non-ALB type, we need to wrap in ApplicationLoadBalancerResponse + if (_model.LambdaMethod.ReturnsVoid) + { + + this.Write(" "); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ContainingType.Name.ToCamelCase())); + this.Write("."); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.Name)); + this.Write("("); + this.Write(this.ToStringHelper.ToStringWithCulture(_parameterSignature)); + this.Write(");\r\n"); + + } + else if (_model.LambdaMethod.ReturnsVoidTask) + { + + this.Write(" await "); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ContainingType.Name.ToCamelCase())); + this.Write("."); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.Name)); + this.Write("("); + this.Write(this.ToStringHelper.ToStringWithCulture(_parameterSignature)); + this.Write(");\r\n"); + + } + else + { + + this.Write(" var response = "); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ReturnsGenericTask ? "await " : "")); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ContainingType.Name.ToCamelCase())); + this.Write("."); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.Name)); + this.Write("("); + this.Write(this.ToStringHelper.ToStringWithCulture(_parameterSignature)); + this.Write(");\r\n"); + + if (_model.LambdaMethod.ReturnType.IsValueType) + { + + this.Write("\r\n var body = response.ToString();\r\n"); + + } + else if (_model.LambdaMethod.ReturnType.IsString()) + { + // no action needed, response is already a string + } + else + { + + this.Write(" var memoryStream = new MemoryStream();\r\n" + + " serializer.Serialize(response, memoryStream);\r\n" + + " memoryStream.Position = 0;\r\n\r\n" + + " // convert stream to string\r\n" + + " StreamReader reader = new StreamReader( memoryStream );\r\n" + + " var body = reader.ReadToEnd();\r\n"); + + } + } + + this.Write("\r\n return new Amazon.Lambda.ApplicationLoadBalancerEvents.ApplicationLoadBalancerResponse\r\n {\r\n"); + + if (!_model.LambdaMethod.ReturnsVoid && !_model.LambdaMethod.ReturnsVoidTask) + { + + this.Write(" Body = "); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ReturnType.IsString() ? "response" : "body")); + this.Write(",\r\n Headers = new Dictionary\r\n {\r\n {\"Content-Type\", "); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ReturnType.IsString() ? "\"text/plain\"" : "\"application/json\"")); + this.Write("}\r\n },\r\n"); + + } + + this.Write(" StatusCode = 200\r\n };\r\n"); + + } + + return this.GenerationEnvironment.ToString(); + } + } + + #region Base class + /// + /// Base class for this transformation + /// + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "18.0.0.0")] + public class ALBInvokeBase + { + #region Fields + private global::System.Text.StringBuilder generationEnvironmentField; + private global::System.CodeDom.Compiler.CompilerErrorCollection errorsField; + private global::System.Collections.Generic.List indentLengthsField; + private string currentIndentField = ""; + private bool endsWithNewline; + private global::System.Collections.Generic.IDictionary sessionField; + #endregion + #region Properties + /// + /// The string builder that generation-time code is using to assemble generated output + /// + public System.Text.StringBuilder GenerationEnvironment + { + get + { + if ((this.generationEnvironmentField == null)) + { + this.generationEnvironmentField = new global::System.Text.StringBuilder(); + } + return this.generationEnvironmentField; + } + set + { + this.generationEnvironmentField = value; + } + } + /// + /// The error collection for the generation process + /// + public System.CodeDom.Compiler.CompilerErrorCollection Errors + { + get + { + if ((this.errorsField == null)) + { + this.errorsField = new global::System.CodeDom.Compiler.CompilerErrorCollection(); + } + return this.errorsField; + } + } + /// + /// A list of the lengths of each indent that was added with PushIndent + /// + private System.Collections.Generic.List indentLengths + { + get + { + if ((this.indentLengthsField == null)) + { + this.indentLengthsField = new global::System.Collections.Generic.List(); + } + return this.indentLengthsField; + } + } + /// + /// Gets the current indent we use when adding lines to the output + /// + public string CurrentIndent + { + get + { + return this.currentIndentField; + } + } + /// + /// Current transformation session + /// + public virtual global::System.Collections.Generic.IDictionary Session + { + get + { + return this.sessionField; + } + set + { + this.sessionField = value; + } + } + #endregion + #region Transform-time helpers + /// + /// Write text directly into the generated output + /// + public void Write(string textToAppend) + { + if (string.IsNullOrEmpty(textToAppend)) + { + return; + } + if (((this.GenerationEnvironment.Length == 0) + || this.endsWithNewline)) + { + this.GenerationEnvironment.Append(this.currentIndentField); + this.endsWithNewline = false; + } + if (textToAppend.EndsWith(global::System.Environment.NewLine, global::System.StringComparison.CurrentCulture)) + { + this.endsWithNewline = true; + } + if ((this.currentIndentField.Length == 0)) + { + this.GenerationEnvironment.Append(textToAppend); + return; + } + textToAppend = textToAppend.Replace(global::System.Environment.NewLine, (global::System.Environment.NewLine + this.currentIndentField)); + if (this.endsWithNewline) + { + this.GenerationEnvironment.Append(textToAppend, 0, (textToAppend.Length - this.currentIndentField.Length)); + } + else + { + this.GenerationEnvironment.Append(textToAppend); + } + } + /// + /// Write text directly into the generated output + /// + public void WriteLine(string textToAppend) + { + this.Write(textToAppend); + this.GenerationEnvironment.AppendLine(); + this.endsWithNewline = true; + } + /// + /// Write formatted text directly into the generated output + /// + public void Write(string format, params object[] args) + { + this.Write(string.Format(global::System.Globalization.CultureInfo.CurrentCulture, format, args)); + } + /// + /// Write formatted text directly into the generated output + /// + public void WriteLine(string format, params object[] args) + { + this.WriteLine(string.Format(global::System.Globalization.CultureInfo.CurrentCulture, format, args)); + } + /// + /// Raise an error + /// + public void Error(string message) + { + System.CodeDom.Compiler.CompilerError error = new global::System.CodeDom.Compiler.CompilerError(); + error.ErrorText = message; + this.Errors.Add(error); + } + /// + /// Raise a warning + /// + public void Warning(string message) + { + System.CodeDom.Compiler.CompilerError error = new global::System.CodeDom.Compiler.CompilerError(); + error.ErrorText = message; + error.IsWarning = true; + this.Errors.Add(error); + } + /// + /// Increase the indent + /// + public void PushIndent(string indent) + { + if ((indent == null)) + { + throw new global::System.ArgumentNullException("indent"); + } + this.currentIndentField = (this.currentIndentField + indent); + this.indentLengths.Add(indent.Length); + } + /// + /// Remove the last indent that was added with PushIndent + /// + public string PopIndent() + { + string returnValue = ""; + if ((this.indentLengths.Count > 0)) + { + int indentLength = this.indentLengths[(this.indentLengths.Count - 1)]; + this.indentLengths.RemoveAt((this.indentLengths.Count - 1)); + if ((indentLength > 0)) + { + returnValue = this.currentIndentField.Substring((this.currentIndentField.Length - indentLength)); + this.currentIndentField = this.currentIndentField.Remove((this.currentIndentField.Length - indentLength)); + } + } + return returnValue; + } + /// + /// Remove any indentation + /// + public void ClearIndent() + { + this.indentLengths.Clear(); + this.currentIndentField = ""; + } + #endregion + #region ToString Helpers + /// + /// Utility class to produce culture-oriented representation of an object as a string. + /// + public class ToStringInstanceHelper + { + private System.IFormatProvider formatProviderField = global::System.Globalization.CultureInfo.InvariantCulture; + /// + /// Gets or sets format provider to be used by ToStringWithCulture method. + /// + public System.IFormatProvider FormatProvider + { + get + { + return this.formatProviderField ; + } + set + { + if ((value != null)) + { + this.formatProviderField = value; + } + } + } + /// + /// This is called from the compile/run appdomain to convert objects within an expression block to a string + /// + public string ToStringWithCulture(object objectToConvert) + { + if ((objectToConvert == null)) + { + throw new global::System.ArgumentNullException("objectToConvert"); + } + System.Type t = objectToConvert.GetType(); + System.Reflection.MethodInfo method = t.GetMethod("ToString", new System.Type[] { + typeof(System.IFormatProvider)}); + if ((method == null)) + { + return objectToConvert.ToString(); + } + else + { + return ((string)(method.Invoke(objectToConvert, new object[] { + this.formatProviderField }))); + } + } + } + private ToStringInstanceHelper toStringHelperField = new ToStringInstanceHelper(); + /// + /// Helper to produce culture-oriented representation of an object as a string + /// + public ToStringInstanceHelper ToStringHelper + { + get + { + return this.toStringHelperField; + } + } + #endregion + } + #endregion +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvoke.tt b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvoke.tt new file mode 100644 index 000000000..e4a8a32fb --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvoke.tt @@ -0,0 +1,98 @@ +<#@ template language="C#" #> +<#@ assembly name="System.Core" #> +<#@ import namespace="System.Linq" #> +<#@ import namespace="System.Text" #> +<#@ import namespace="System.Collections.Generic" #> +<#@ import namespace="Amazon.Lambda.Annotations.SourceGenerator.Extensions" #> +<#@ import namespace="Amazon.Lambda.Annotations.SourceGenerator.Models" #> +<#@ import namespace="Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes" #> +<# + if (_model.GeneratedMethod.ReturnType.FullName == _model.LambdaMethod.ReturnType.FullName) + { + // User already returns ApplicationLoadBalancerResponse (or Task), + // just pass through. + if (_model.LambdaMethod.ReturnsVoid) + { +#> + <#= _model.LambdaMethod.ContainingType.Name.ToCamelCase() #>.<#= _model.LambdaMethod.Name #>(<#= _parameterSignature #>); +<# + } + else if (_model.LambdaMethod.ReturnsVoidTask) + { +#> + await <#= _model.LambdaMethod.ContainingType.Name.ToCamelCase() #>.<#= _model.LambdaMethod.Name #>(<#= _parameterSignature #>); +<# + } + else + { +#> + var response = <#= _model.LambdaMethod.ReturnsGenericTask ? "await " : "" #><#= _model.LambdaMethod.ContainingType.Name.ToCamelCase() #>.<#= _model.LambdaMethod.Name #>(<#= _parameterSignature #>); + return response; +<# + } + } + else + { + // User returns a non-ALB type, we need to wrap in ApplicationLoadBalancerResponse + if (_model.LambdaMethod.ReturnsVoid) + { +#> + <#= _model.LambdaMethod.ContainingType.Name.ToCamelCase() #>.<#= _model.LambdaMethod.Name #>(<#= _parameterSignature #>); +<# + } + else if (_model.LambdaMethod.ReturnsVoidTask) + { +#> + await <#= _model.LambdaMethod.ContainingType.Name.ToCamelCase() #>.<#= _model.LambdaMethod.Name #>(<#= _parameterSignature #>); +<# + } + else + { +#> + var response = <#= _model.LambdaMethod.ReturnsGenericTask ? "await " : "" #><#= _model.LambdaMethod.ContainingType.Name.ToCamelCase() #>.<#= _model.LambdaMethod.Name #>(<#= _parameterSignature #>); +<# + if (_model.LambdaMethod.ReturnType.IsValueType) + { +#> + + var body = response.ToString(); +<# + } + else if (_model.LambdaMethod.ReturnType.IsString()) + { + // no action needed, response is already a string + } + else + { +#> + var memoryStream = new MemoryStream(); + serializer.Serialize(response, memoryStream); + memoryStream.Position = 0; + + // convert stream to string + StreamReader reader = new StreamReader( memoryStream ); + var body = reader.ReadToEnd(); +<# + } + } +#> + + return new Amazon.Lambda.ApplicationLoadBalancerEvents.ApplicationLoadBalancerResponse + { +<# + if (!_model.LambdaMethod.ReturnsVoid && !_model.LambdaMethod.ReturnsVoidTask) + { +#> + Body = <#= _model.LambdaMethod.ReturnType.IsString() ? "response" : "body" #>, + Headers = new Dictionary + { + {"Content-Type", <#= _model.LambdaMethod.ReturnType.IsString() ? "\"text/plain\"" : "\"application/json\"" #>} + }, +<# + } +#> + StatusCode = 200 + }; +<# + } +#> diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvokeCode.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvokeCode.cs new file mode 100644 index 000000000..d7add815d --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvokeCode.cs @@ -0,0 +1,17 @@ +using Amazon.Lambda.Annotations.SourceGenerator.Models; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Templates +{ + public partial class ALBInvoke + { + private readonly LambdaFunctionModel _model; + + public readonly string _parameterSignature; + + public ALBInvoke(LambdaFunctionModel model, string parameterSignature) + { + _model = model; + _parameterSignature = parameterSignature; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParameters.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParameters.cs new file mode 100644 index 000000000..a6ce865bc --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParameters.cs @@ -0,0 +1,604 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +namespace Amazon.Lambda.Annotations.SourceGenerator.Templates +{ + using System.Linq; + using System.Text; + using System.Collections.Generic; + using Amazon.Lambda.Annotations.SourceGenerator.Extensions; + using Amazon.Lambda.Annotations.SourceGenerator.Models; + using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; + using System; + + /// + /// Class to produce the template output + /// + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "18.0.0.0")] + public partial class ALBSetupParameters : ALBSetupParametersBase + { + /// + /// Create the template output + /// + public virtual string TransformText() + { + + ParameterSignature = string.Join(", ", _model.LambdaMethod.Parameters + .Select(p => + { + // Pass the same context parameter for ILambdaContext that comes from the generated method. + if (p.Type.FullName == TypeFullNames.ILambdaContext) + { + return "__context__"; + } + + // Pass the same request parameter for ALB Request Type that comes from the generated method. + if (TypeFullNames.ALBRequests.Contains(p.Type.FullName)) + { + return "__request__"; + } + + return p.Name; + })); + + var albApiAttribute = _model.LambdaMethod.Attributes.FirstOrDefault(att => att.Type.FullName == TypeFullNames.ALBApiAttribute) as AttributeModel; + + // Determine whether multi-value headers are enabled + var useMultiValue = albApiAttribute?.Data?.IsMultiValueHeadersSet == true && albApiAttribute.Data.MultiValueHeaders; + + if (_model.LambdaMethod.Parameters.HasConvertibleParameter()) + { + + this.Write(" var validationErrors = new List();\r\n\r\n"); + + } + + foreach (var parameter in _model.LambdaMethod.Parameters) + { + if (parameter.Type.FullName == TypeFullNames.ILambdaContext || TypeFullNames.ALBRequests.Contains(parameter.Type.FullName)) + { + // No action required for ILambdaContext and ALB RequestType, they are passed from the generated method parameter directly to the original method. + } + else if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.FromServiceAttribute)) + { + + this.Write(" var "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = scope.ServiceProvider.GetRequiredService<"); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Type.FullName)); + this.Write(">();\r\n"); + + } + else if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.ALBFromQueryAttribute)) + { + var fromQueryAttribute = parameter.Attributes.First(att => att.Type.FullName == TypeFullNames.ALBFromQueryAttribute) as AttributeModel; + + // Use parameter name as key, if Name has not specified explicitly in the attribute definition. + var parameterKey = fromQueryAttribute?.Data?.Name ?? parameter.Name; + + var queryStringParameters = useMultiValue ? "MultiValueQueryStringParameters" : "QueryStringParameters"; + + this.Write(" var "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = default("); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Type.FullName)); + this.Write(");\r\n"); + + if (parameter.Type.IsEnumerable && parameter.Type.IsGenericType) + { + if (useMultiValue) + { + // Multi-value mode: MultiValueQueryStringParameters is IDictionary> + if (parameter.Type.TypeArguments.Count != 1) + { + throw new NotSupportedException("Only one type argument is supported for generic types."); + } + + var typeArgument = parameter.Type.TypeArguments.First(); + this.Write(" if (__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(queryStringParameters)); + this.Write("?.ContainsKey(\""); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\") == true)\r\n {\r\n "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = __request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(queryStringParameters)); + this.Write("[\""); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\"]\r\n .Select(q =>\r\n {\r\n try\r\n {\r\n return ("); + this.Write(this.ToStringHelper.ToStringWithCulture(typeArgument.FullName)); + this.Write(")Convert.ChangeType(q, typeof("); + this.Write(this.ToStringHelper.ToStringWithCulture(typeArgument.FullNameWithoutAnnotations)); + this.Write("));\r\n }\r\n catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException)\r\n {\r\n validationErrors.Add($\"Value {q} at \'"); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\' failed to satisfy constraint: {e.Message}\");\r\n return default;\r\n }\r\n })\r\n .ToList();\r\n }\r\n\r\n"); + + } + else + { + // Single-value mode: QueryStringParameters is IDictionary + // Split by comma to support multiple values + if (parameter.Type.TypeArguments.Count != 1) + { + throw new NotSupportedException("Only one type argument is supported for generic types."); + } + + var typeArgument = parameter.Type.TypeArguments.First(); + this.Write(" if (__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(queryStringParameters)); + this.Write("?.ContainsKey(\""); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\") == true)\r\n {\r\n "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = __request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(queryStringParameters)); + this.Write("[\""); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\"].Split(\",\")\r\n .Select(q =>\r\n {\r\n try\r\n {\r\n return ("); + this.Write(this.ToStringHelper.ToStringWithCulture(typeArgument.FullName)); + this.Write(")Convert.ChangeType(q, typeof("); + this.Write(this.ToStringHelper.ToStringWithCulture(typeArgument.FullNameWithoutAnnotations)); + this.Write("));\r\n }\r\n catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException)\r\n {\r\n validationErrors.Add($\"Value {q} at \'"); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\' failed to satisfy constraint: {e.Message}\");\r\n return default;\r\n }\r\n })\r\n .ToList();\r\n }\r\n\r\n"); + + } + } + else + { + // Non-generic types are mapped directly to the target parameter. + this.Write(" if (__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(queryStringParameters)); + this.Write("?.ContainsKey(\""); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\") == true)\r\n {\r\n try\r\n {\r\n "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = ("); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Type.FullName)); + this.Write(")Convert.ChangeType(__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(queryStringParameters)); + this.Write("[\""); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\"], typeof("); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Type.FullNameWithoutAnnotations)); + this.Write("));\r\n }\r\n catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException)\r\n {\r\n validationErrors.Add($\"Value {__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(queryStringParameters)); + this.Write("[\""); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\"]} at \'"); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\' failed to satisfy constraint: {e.Message}\");\r\n }\r\n }\r\n\r\n"); + + } + + } + else if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.ALBFromHeaderAttribute)) + { + var fromHeaderAttribute = + parameter.Attributes.First(att => att.Type.FullName == TypeFullNames.ALBFromHeaderAttribute) as + AttributeModel; + + // Use parameter name as key, if Name has not specified explicitly in the attribute definition. + var headerKey = fromHeaderAttribute?.Data?.Name ?? parameter.Name; + + var headers = useMultiValue ? "MultiValueHeaders" : "Headers"; + + this.Write(" var "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = default("); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Type.FullName)); + this.Write(");\r\n"); + + if (parameter.Type.IsEnumerable && parameter.Type.IsGenericType) + { + if (useMultiValue) + { + // Multi-value mode: MultiValueHeaders is IDictionary> + if (parameter.Type.TypeArguments.Count != 1) + { + throw new NotSupportedException("Only one type argument is supported for generic types."); + } + + var typeArgument = parameter.Type.TypeArguments.First(); + this.Write(" if (__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(headers)); + this.Write("?.Any(x => string.Equals(x.Key, \""); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\", StringComparison.OrdinalIgnoreCase)) == true)\r\n {\r\n "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = __request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(headers)); + this.Write(".First(x => string.Equals(x.Key, \""); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\", StringComparison.OrdinalIgnoreCase)).Value\r\n .Select(q =>\r\n {\r\n try\r\n {\r\n return ("); + this.Write(this.ToStringHelper.ToStringWithCulture(typeArgument.FullName)); + this.Write(")Convert.ChangeType(q, typeof("); + this.Write(this.ToStringHelper.ToStringWithCulture(typeArgument.FullNameWithoutAnnotations)); + this.Write("));\r\n }\r\n catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException)\r\n {\r\n validationErrors.Add($\"Value {q} at \'"); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\' failed to satisfy constraint: {e.Message}\");\r\n return default;\r\n }\r\n })\r\n .ToList();\r\n }\r\n\r\n"); + + } + else + { + // Single-value mode: Headers is IDictionary + // Split by comma to support multiple values + if (parameter.Type.TypeArguments.Count != 1) + { + throw new NotSupportedException("Only one type argument is supported for generic types."); + } + + var typeArgument = parameter.Type.TypeArguments.First(); + this.Write(" if (__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(headers)); + this.Write("?.Any(x => string.Equals(x.Key, \""); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\", StringComparison.OrdinalIgnoreCase)) == true)\r\n {\r\n "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = __request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(headers)); + this.Write(".First(x => string.Equals(x.Key, \""); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\", StringComparison.OrdinalIgnoreCase)).Value.Split(\",\")\r\n .Select(q =>\r\n {\r\n try\r\n {\r\n return ("); + this.Write(this.ToStringHelper.ToStringWithCulture(typeArgument.FullName)); + this.Write(")Convert.ChangeType(q, typeof("); + this.Write(this.ToStringHelper.ToStringWithCulture(typeArgument.FullNameWithoutAnnotations)); + this.Write("));\r\n }\r\n catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException)\r\n {\r\n validationErrors.Add($\"Value {q} at \'"); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\' failed to satisfy constraint: {e.Message}\");\r\n return default;\r\n }\r\n })\r\n .ToList();\r\n }\r\n\r\n"); + + } + } + else + { + // Non-generic types are mapped directly to the target parameter. + this.Write(" if (__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(headers)); + this.Write("?.Any(x => string.Equals(x.Key, \""); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\", StringComparison.OrdinalIgnoreCase)) == true)\r\n {\r\n try\r\n {\r\n "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = ("); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Type.FullName)); + this.Write(")Convert.ChangeType(__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(headers)); + this.Write(".First(x => string.Equals(x.Key, \""); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\", StringComparison.OrdinalIgnoreCase)).Value, typeof("); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Type.FullNameWithoutAnnotations)); + this.Write("));\r\n }\r\n catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException)\r\n {\r\n validationErrors.Add($\"Value {__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(headers)); + this.Write(".First(x => string.Equals(x.Key, \""); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\", StringComparison.OrdinalIgnoreCase)).Value} at \'"); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\' failed to satisfy constraint: {e.Message}\");\r\n }\r\n }\r\n\r\n"); + + } + } + else if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.ALBFromBodyAttribute)) + { + // string parameter does not need to be de-serialized + if (parameter.Type.IsString()) + { + + this.Write(" var "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = __request__.Body;\r\n\r\n"); + + } + else + { + + this.Write(" var "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = default("); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Type.FullName)); + this.Write(");\r\n try\r\n {\r\n // convert string to stream\r\n var byteArray = Encoding.UTF8.GetBytes(__request__.Body);\r\n var stream = new MemoryStream(byteArray);\r\n "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = serializer.Deserialize<"); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Type.FullName)); + this.Write(">(stream);\r\n }\r\n catch (Exception e)\r\n {\r\n validationErrors.Add($\"Value {__request__.Body} at \'body\' failed to satisfy constraint: {e.Message}\");\r\n }\r\n\r\n"); + + } + } + else + { + throw new NotSupportedException($"{parameter.Name} parameter of type {parameter.Type.FullName} passing is not supported for ALB functions. Use [FromHeader], [FromQuery], [FromBody], or [FromServices] attributes."); + } + } + + if (_model.LambdaMethod.Parameters.HasConvertibleParameter()) + { + + this.Write(" // return 400 Bad Request if there exists a validation error\r\n" + + " if (validationErrors.Any())\r\n" + + " {\r\n" + + " var errorResult = new Amazon.Lambda.ApplicationLoadBalancerEvents.ApplicationLoadBalancerResponse\r\n" + + " {\r\n" + + " Body = @$\"{{\"\"message\"\": \"\"{validationErrors.Count} validation error(s) detected: {string.Join(\",\", validationErrors)}\"\"}}\",\r\n" + + " Headers = new Dictionary\r\n" + + " {\r\n" + + " {\"Content-Type\", \"application/json\"}\r\n" + + " },\r\n" + + " StatusCode = 400\r\n" + + " };\r\n" + + " return errorResult;\r\n" + + " }\r\n\r\n"); + + } + + return this.GenerationEnvironment.ToString(); + } + } + + #region Base class + /// + /// Base class for this transformation + /// + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "18.0.0.0")] + public class ALBSetupParametersBase + { + #region Fields + private global::System.Text.StringBuilder generationEnvironmentField; + private global::System.CodeDom.Compiler.CompilerErrorCollection errorsField; + private global::System.Collections.Generic.List indentLengthsField; + private string currentIndentField = ""; + private bool endsWithNewline; + private global::System.Collections.Generic.IDictionary sessionField; + #endregion + #region Properties + /// + /// The string builder that generation-time code is using to assemble generated output + /// + public System.Text.StringBuilder GenerationEnvironment + { + get + { + if ((this.generationEnvironmentField == null)) + { + this.generationEnvironmentField = new global::System.Text.StringBuilder(); + } + return this.generationEnvironmentField; + } + set + { + this.generationEnvironmentField = value; + } + } + /// + /// The error collection for the generation process + /// + public System.CodeDom.Compiler.CompilerErrorCollection Errors + { + get + { + if ((this.errorsField == null)) + { + this.errorsField = new global::System.CodeDom.Compiler.CompilerErrorCollection(); + } + return this.errorsField; + } + } + /// + /// A list of the lengths of each indent that was added with PushIndent + /// + private System.Collections.Generic.List indentLengths + { + get + { + if ((this.indentLengthsField == null)) + { + this.indentLengthsField = new global::System.Collections.Generic.List(); + } + return this.indentLengthsField; + } + } + /// + /// Gets the current indent we use when adding lines to the output + /// + public string CurrentIndent + { + get + { + return this.currentIndentField; + } + } + /// + /// Current transformation session + /// + public virtual global::System.Collections.Generic.IDictionary Session + { + get + { + return this.sessionField; + } + set + { + this.sessionField = value; + } + } + #endregion + #region Transform-time helpers + /// + /// Write text directly into the generated output + /// + public void Write(string textToAppend) + { + if (string.IsNullOrEmpty(textToAppend)) + { + return; + } + if (((this.GenerationEnvironment.Length == 0) + || this.endsWithNewline)) + { + this.GenerationEnvironment.Append(this.currentIndentField); + this.endsWithNewline = false; + } + if (textToAppend.EndsWith(global::System.Environment.NewLine, global::System.StringComparison.CurrentCulture)) + { + this.endsWithNewline = true; + } + if ((this.currentIndentField.Length == 0)) + { + this.GenerationEnvironment.Append(textToAppend); + return; + } + textToAppend = textToAppend.Replace(global::System.Environment.NewLine, (global::System.Environment.NewLine + this.currentIndentField)); + if (this.endsWithNewline) + { + this.GenerationEnvironment.Append(textToAppend, 0, (textToAppend.Length - this.currentIndentField.Length)); + } + else + { + this.GenerationEnvironment.Append(textToAppend); + } + } + /// + /// Write text directly into the generated output + /// + public void WriteLine(string textToAppend) + { + this.Write(textToAppend); + this.GenerationEnvironment.AppendLine(); + this.endsWithNewline = true; + } + /// + /// Write formatted text directly into the generated output + /// + public void Write(string format, params object[] args) + { + this.Write(string.Format(global::System.Globalization.CultureInfo.CurrentCulture, format, args)); + } + /// + /// Write formatted text directly into the generated output + /// + public void WriteLine(string format, params object[] args) + { + this.WriteLine(string.Format(global::System.Globalization.CultureInfo.CurrentCulture, format, args)); + } + /// + /// Raise an error + /// + public void Error(string message) + { + System.CodeDom.Compiler.CompilerError error = new global::System.CodeDom.Compiler.CompilerError(); + error.ErrorText = message; + this.Errors.Add(error); + } + /// + /// Raise a warning + /// + public void Warning(string message) + { + System.CodeDom.Compiler.CompilerError error = new global::System.CodeDom.Compiler.CompilerError(); + error.ErrorText = message; + error.IsWarning = true; + this.Errors.Add(error); + } + /// + /// Increase the indent + /// + public void PushIndent(string indent) + { + if ((indent == null)) + { + throw new global::System.ArgumentNullException("indent"); + } + this.currentIndentField = (this.currentIndentField + indent); + this.indentLengths.Add(indent.Length); + } + /// + /// Remove the last indent that was added with PushIndent + /// + public string PopIndent() + { + string returnValue = ""; + if ((this.indentLengths.Count > 0)) + { + int indentLength = this.indentLengths[(this.indentLengths.Count - 1)]; + this.indentLengths.RemoveAt((this.indentLengths.Count - 1)); + if ((indentLength > 0)) + { + returnValue = this.currentIndentField.Substring((this.currentIndentField.Length - indentLength)); + this.currentIndentField = this.currentIndentField.Remove((this.currentIndentField.Length - indentLength)); + } + } + return returnValue; + } + /// + /// Remove any indentation + /// + public void ClearIndent() + { + this.indentLengths.Clear(); + this.currentIndentField = ""; + } + #endregion + #region ToString Helpers + /// + /// Utility class to produce culture-oriented representation of an object as a string. + /// + public class ToStringInstanceHelper + { + private System.IFormatProvider formatProviderField = global::System.Globalization.CultureInfo.InvariantCulture; + /// + /// Gets or sets format provider to be used by ToStringWithCulture method. + /// + public System.IFormatProvider FormatProvider + { + get + { + return this.formatProviderField ; + } + set + { + if ((value != null)) + { + this.formatProviderField = value; + } + } + } + /// + /// This is called from the compile/run appdomain to convert objects within an expression block to a string + /// + public string ToStringWithCulture(object objectToConvert) + { + if ((objectToConvert == null)) + { + throw new global::System.ArgumentNullException("objectToConvert"); + } + System.Type t = objectToConvert.GetType(); + System.Reflection.MethodInfo method = t.GetMethod("ToString", new System.Type[] { + typeof(System.IFormatProvider)}); + if ((method == null)) + { + return objectToConvert.ToString(); + } + else + { + return ((string)(method.Invoke(objectToConvert, new object[] { + this.formatProviderField }))); + } + } + } + private ToStringInstanceHelper toStringHelperField = new ToStringInstanceHelper(); + /// + /// Helper to produce culture-oriented representation of an object as a string + /// + public ToStringInstanceHelper ToStringHelper + { + get + { + return this.toStringHelperField; + } + } + #endregion + } + #endregion +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParameters.tt b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParameters.tt new file mode 100644 index 000000000..c90cff8b6 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParameters.tt @@ -0,0 +1,303 @@ +<#@ template language="C#" #> +<#@ assembly name="System.Core" #> +<#@ import namespace="System.Linq" #> +<#@ import namespace="System.Text" #> +<#@ import namespace="System.Collections.Generic" #> +<#@ import namespace="Amazon.Lambda.Annotations.SourceGenerator.Extensions" #> +<#@ import namespace="Amazon.Lambda.Annotations.SourceGenerator.Models" #> +<#@ import namespace="Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes" #> +<# + ParameterSignature = string.Join(", ", _model.LambdaMethod.Parameters + .Select(p => + { + // Pass the same context parameter for ILambdaContext that comes from the generated method. + if (p.Type.FullName == TypeFullNames.ILambdaContext) + { + return "__context__"; + } + + // Pass the same request parameter for ALB Request Type that comes from the generated method. + if (TypeFullNames.ALBRequests.Contains(p.Type.FullName)) + { + return "__request__"; + } + + return p.Name; + })); + + var albApiAttribute = _model.LambdaMethod.Attributes.FirstOrDefault(att => att.Type.FullName == TypeFullNames.ALBApiAttribute) as AttributeModel; + + // Determine whether multi-value headers are enabled + var useMultiValue = albApiAttribute?.Data?.IsMultiValueHeadersSet == true && albApiAttribute.Data.MultiValueHeaders; + + if (_model.LambdaMethod.Parameters.HasConvertibleParameter()) + { +#> + var validationErrors = new List(); + +<# + } + + foreach (var parameter in _model.LambdaMethod.Parameters) + { + if (parameter.Type.FullName == TypeFullNames.ILambdaContext || TypeFullNames.ALBRequests.Contains(parameter.Type.FullName)) + { + // No action required for ILambdaContext and ALB RequestType, they are passed from the generated method parameter directly to the original method. + } + else if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.FromServiceAttribute)) + { +#> + var <#= parameter.Name #> = scope.ServiceProvider.GetRequiredService<<#= parameter.Type.FullName #>>(); +<# + } + else if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.ALBFromQueryAttribute)) + { + var fromQueryAttribute = parameter.Attributes.First(att => att.Type.FullName == TypeFullNames.ALBFromQueryAttribute) as AttributeModel; + + // Use parameter name as key, if Name has not specified explicitly in the attribute definition. + var parameterKey = fromQueryAttribute?.Data?.Name ?? parameter.Name; + + var queryStringParameters = useMultiValue ? "MultiValueQueryStringParameters" : "QueryStringParameters"; + +#> + var <#= parameter.Name #> = default(<#= parameter.Type.FullName #>); +<# + + if (parameter.Type.IsEnumerable && parameter.Type.IsGenericType) + { + if (useMultiValue) + { + // Multi-value mode: MultiValueQueryStringParameters is IDictionary> + if (parameter.Type.TypeArguments.Count != 1) + { + throw new NotSupportedException("Only one type argument is supported for generic types."); + } + + var typeArgument = parameter.Type.TypeArguments.First(); +#> + if (__request__.<#= queryStringParameters #>?.ContainsKey("<#= parameterKey #>") == true) + { + <#= parameter.Name #> = __request__.<#= queryStringParameters #>["<#= parameterKey #>"] + .Select(q => + { + try + { + return (<#= typeArgument.FullName #>)Convert.ChangeType(q, typeof(<#= typeArgument.FullNameWithoutAnnotations #>)); + } + catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException) + { + validationErrors.Add($"Value {q} at '<#= parameterKey #>' failed to satisfy constraint: {e.Message}"); + return default; + } + }) + .ToList(); + } + +<# + } + else + { + // Single-value mode: QueryStringParameters is IDictionary + // Split by comma to support multiple values + if (parameter.Type.TypeArguments.Count != 1) + { + throw new NotSupportedException("Only one type argument is supported for generic types."); + } + + var typeArgument = parameter.Type.TypeArguments.First(); +#> + if (__request__.<#= queryStringParameters #>?.ContainsKey("<#= parameterKey #>") == true) + { + <#= parameter.Name #> = __request__.<#= queryStringParameters #>["<#= parameterKey #>"].Split(",") + .Select(q => + { + try + { + return (<#= typeArgument.FullName #>)Convert.ChangeType(q, typeof(<#= typeArgument.FullNameWithoutAnnotations #>)); + } + catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException) + { + validationErrors.Add($"Value {q} at '<#= parameterKey #>' failed to satisfy constraint: {e.Message}"); + return default; + } + }) + .ToList(); + } + +<# + } + } + else + { + // Non-generic types are mapped directly to the target parameter. +#> + if (__request__.<#= queryStringParameters #>?.ContainsKey("<#= parameterKey #>") == true) + { + try + { + <#= parameter.Name #> = (<#= parameter.Type.FullName #>)Convert.ChangeType(__request__.<#= queryStringParameters #>["<#= parameterKey #>"], typeof(<#= parameter.Type.FullNameWithoutAnnotations #>)); + } + catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException) + { + validationErrors.Add($"Value {__request__.<#= queryStringParameters #>["<#= parameterKey #>"]} at '<#= parameterKey #>' failed to satisfy constraint: {e.Message}"); + } + } + +<# + } + + } + else if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.ALBFromHeaderAttribute)) + { + var fromHeaderAttribute = + parameter.Attributes.First(att => att.Type.FullName == TypeFullNames.ALBFromHeaderAttribute) as + AttributeModel; + + // Use parameter name as key, if Name has not specified explicitly in the attribute definition. + var headerKey = fromHeaderAttribute?.Data?.Name ?? parameter.Name; + + var headers = useMultiValue ? "MultiValueHeaders" : "Headers"; + +#> + var <#= parameter.Name #> = default(<#= parameter.Type.FullName #>); +<# + + if (parameter.Type.IsEnumerable && parameter.Type.IsGenericType) + { + if (useMultiValue) + { + // Multi-value mode: MultiValueHeaders is IDictionary> + if (parameter.Type.TypeArguments.Count != 1) + { + throw new NotSupportedException("Only one type argument is supported for generic types."); + } + + var typeArgument = parameter.Type.TypeArguments.First(); +#> + if (__request__.<#= headers #>?.Any(x => string.Equals(x.Key, "<#= headerKey #>", StringComparison.OrdinalIgnoreCase)) == true) + { + <#= parameter.Name #> = __request__.<#= headers #>.First(x => string.Equals(x.Key, "<#= headerKey #>", StringComparison.OrdinalIgnoreCase)).Value + .Select(q => + { + try + { + return (<#= typeArgument.FullName #>)Convert.ChangeType(q, typeof(<#= typeArgument.FullNameWithoutAnnotations #>)); + } + catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException) + { + validationErrors.Add($"Value {q} at '<#= headerKey #>' failed to satisfy constraint: {e.Message}"); + return default; + } + }) + .ToList(); + } + +<# + } + else + { + // Single-value mode: Headers is IDictionary + // Split by comma to support multiple values + if (parameter.Type.TypeArguments.Count != 1) + { + throw new NotSupportedException("Only one type argument is supported for generic types."); + } + + var typeArgument = parameter.Type.TypeArguments.First(); +#> + if (__request__.<#= headers #>?.Any(x => string.Equals(x.Key, "<#= headerKey #>", StringComparison.OrdinalIgnoreCase)) == true) + { + <#= parameter.Name #> = __request__.<#= headers #>.First(x => string.Equals(x.Key, "<#= headerKey #>", StringComparison.OrdinalIgnoreCase)).Value.Split(",") + .Select(q => + { + try + { + return (<#= typeArgument.FullName #>)Convert.ChangeType(q, typeof(<#= typeArgument.FullNameWithoutAnnotations #>)); + } + catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException) + { + validationErrors.Add($"Value {q} at '<#= headerKey #>' failed to satisfy constraint: {e.Message}"); + return default; + } + }) + .ToList(); + } + +<# + } + } + else + { + // Non-generic types are mapped directly to the target parameter. +#> + if (__request__.<#= headers #>?.Any(x => string.Equals(x.Key, "<#= headerKey #>", StringComparison.OrdinalIgnoreCase)) == true) + { + try + { + <#= parameter.Name #> = (<#= parameter.Type.FullName #>)Convert.ChangeType(__request__.<#= headers #>.First(x => string.Equals(x.Key, "<#= headerKey #>", StringComparison.OrdinalIgnoreCase)).Value, typeof(<#= parameter.Type.FullNameWithoutAnnotations #>)); + } + catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException) + { + validationErrors.Add($"Value {__request__.<#= headers #>.First(x => string.Equals(x.Key, "<#= headerKey #>", StringComparison.OrdinalIgnoreCase)).Value} at '<#= headerKey #>' failed to satisfy constraint: {e.Message}"); + } + } + +<# + } + } + else if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.ALBFromBodyAttribute)) + { + // string parameter does not need to be de-serialized + if (parameter.Type.IsString()) + { + #> + var <#= parameter.Name #> = __request__.Body; + +<# + } + else + { + #> + var <#= parameter.Name #> = default(<#= parameter.Type.FullName #>); + try + { + // convert string to stream + var byteArray = Encoding.UTF8.GetBytes(__request__.Body); + var stream = new MemoryStream(byteArray); + <#= parameter.Name #> = serializer.Deserialize<<#= parameter.Type.FullName #>>(stream); + } + catch (Exception e) + { + validationErrors.Add($"Value {__request__.Body} at 'body' failed to satisfy constraint: {e.Message}"); + } + +<# + } + } + else + { + throw new NotSupportedException($"{parameter.Name} parameter of type {parameter.Type.FullName} passing is not supported for ALB functions. Use [FromHeader], [FromQuery], [FromBody], or [FromServices] attributes."); + } + } + + if (_model.LambdaMethod.Parameters.HasConvertibleParameter()) + { +#> + // return 400 Bad Request if there exists a validation error + if (validationErrors.Any()) + { + var errorResult = new Amazon.Lambda.ApplicationLoadBalancerEvents.ApplicationLoadBalancerResponse + { + Body = @$"{{""message"": ""{validationErrors.Count} validation error(s) detected: {string.Join(",", validationErrors)}""}}", + Headers = new Dictionary + { + {"Content-Type", "application/json"} + }, + StatusCode = 400 + }; + return errorResult; + } + +<# + } +#> diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParametersCode.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParametersCode.cs new file mode 100644 index 000000000..49bec9eda --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParametersCode.cs @@ -0,0 +1,16 @@ +using Amazon.Lambda.Annotations.SourceGenerator.Models; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Templates +{ + public partial class ALBSetupParameters + { + private readonly LambdaFunctionModel _model; + + public string ParameterSignature { get; set; } + + public ALBSetupParameters(LambdaFunctionModel model) + { + _model = model; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.cs index e2c2f957f..6e4a30347 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.cs @@ -188,6 +188,12 @@ public virtual string TransformText() this.Write(apiParameters.TransformText()); this.Write(new APIGatewayInvoke(_model, apiParameters.ParameterSignature).TransformText()); } + else if (_model.LambdaMethod.Events.Contains(EventType.ALB)) + { + var albParameters = new ALBSetupParameters(_model); + this.Write(albParameters.TransformText()); + this.Write(new ALBInvoke(_model, albParameters.ParameterSignature).TransformText()); + } else { this.Write(new NoEventMethodBody(_model).TransformText()); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.tt b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.tt index bacf7daf0..aa3c3ab18 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.tt +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.tt @@ -66,6 +66,12 @@ this.Write(new FieldsAndConstructor(_model).TransformText()); this.Write(apiParameters.TransformText()); this.Write(new APIGatewayInvoke(_model, apiParameters.ParameterSignature).TransformText()); } + else if (_model.LambdaMethod.Events.Contains(EventType.ALB)) + { + var albParameters = new ALBSetupParameters(_model); + this.Write(albParameters.TransformText()); + this.Write(new ALBInvoke(_model, albParameters.ParameterSignature).TransformText()); + } else { this.Write(new NoEventMethodBody(_model).TransformText()); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs index 6e15c2175..76871445e 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs @@ -46,6 +46,13 @@ 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 ALBFromQueryAttribute = "Amazon.Lambda.Annotations.ALB.FromQueryAttribute"; + public const string ALBFromHeaderAttribute = "Amazon.Lambda.Annotations.ALB.FromHeaderAttribute"; + public const string ALBFromBodyAttribute = "Amazon.Lambda.Annotations.ALB.FromBodyAttribute"; + public const string LambdaSerializerAttribute = "Amazon.Lambda.Core.LambdaSerializerAttribute"; public const string DefaultLambdaSerializer = "Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer"; @@ -63,11 +70,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/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs index 733124209..c496ac3bf 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs @@ -1,4 +1,5 @@ -using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics; +using Amazon.Lambda.Annotations.ALB; +using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics; using Amazon.Lambda.Annotations.SourceGenerator.Extensions; using Amazon.Lambda.Annotations.SourceGenerator.Models; using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; @@ -59,6 +60,7 @@ internal static bool ValidateFunction(GeneratorExecutionContext context, IMethod // Validate Events ValidateApiGatewayEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateSqsEvents(lambdaFunctionModel, methodLocation, diagnostics); + ValidateAlbEvents(lambdaFunctionModel, methodLocation, diagnostics); return ReportDiagnostics(diagnosticReporter, diagnostics); } @@ -86,6 +88,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; } @@ -106,10 +118,12 @@ private static void ValidateApiGatewayEvents(LambdaFunctionModel lambdaFunctionM diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.AuthorizerResultOnNonAuthorizerFunction, methodLocation)); } - // If the method does not contain any API or Authorizer events, then it cannot have + // If the method does not contain any API, Authorizer, or ALB events, then it cannot have // parameters that are annotated with HTTP API attributes. // Authorizer functions also support FromHeader, FromQuery, FromRoute attributes. - if (!isApiEvent && !isAuthorizerEvent) + // ALB functions also support FromHeader, FromQuery, FromBody attributes. + var isAlbEvent = lambdaFunctionModel.LambdaMethod.Events.Contains(EventType.ALB); + if (!isApiEvent && !isAuthorizerEvent && !isAlbEvent) { foreach (var parameter in lambdaFunctionModel.LambdaMethod.Parameters) { @@ -268,6 +282,86 @@ private static void ValidateSqsEvents(LambdaFunctionModel lambdaFunctionModel, L } } + private static void ValidateAlbEvents(LambdaFunctionModel lambdaFunctionModel, Location methodLocation, List diagnostics) + { + // If the method does not contain any ALB events, then simply return early + if (!lambdaFunctionModel.LambdaMethod.Events.Contains(EventType.ALB)) + { + return; + } + + // Validate ALBApiAttributes + foreach (var att in lambdaFunctionModel.Attributes) + { + if (att.Type.FullName != TypeFullNames.ALBApiAttribute) + continue; + + var albApiAttribute = ((AttributeModel)att).Data; + var validationErrors = albApiAttribute.Validate(); + validationErrors.ForEach(errorMessage => diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidAlbApiAttribute, methodLocation, errorMessage))); + } + + // Validate method parameters + var parameters = lambdaFunctionModel.LambdaMethod.Parameters; + foreach (var parameter in parameters) + { + // [FromRoute] is not supported on ALB functions + if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.FromRouteAttribute)) + { + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.FromRouteNotSupportedOnAlb, methodLocation)); + } + + // Validate [FromQuery] parameter types - only primitive types allowed + if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.ALBFromQueryAttribute)) + { + if (!parameter.Type.IsPrimitiveType() && !parameter.Type.IsPrimitiveEnumerableType()) + { + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.UnsupportedMethodParameterType, methodLocation, parameter.Name, parameter.Type.FullName)); + } + } + + // Validate attribute names for FromQuery and FromHeader + foreach (var att in parameter.Attributes) + { + var parameterAttributeName = string.Empty; + switch (att.Type.FullName) + { + case TypeFullNames.ALBFromQueryAttribute: + if (att is AttributeModel albFromQueryAttribute) + parameterAttributeName = albFromQueryAttribute.Data.Name; + break; + + case TypeFullNames.ALBFromHeaderAttribute: + if (att is AttributeModel albFromHeaderAttribute) + parameterAttributeName = albFromHeaderAttribute.Data.Name; + break; + + default: + break; + } + + if (!string.IsNullOrEmpty(parameterAttributeName) && !_parameterAttributeNameRegex.IsMatch(parameterAttributeName)) + { + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidParameterAttributeName, methodLocation, parameterAttributeName, parameter.Name)); + } + } + + // Validate that every parameter has a recognized binding + // Allowed: ILambdaContext, ApplicationLoadBalancerRequest, [FromServices], [FromQuery], [FromHeader], [FromBody] + if (parameter.Type.FullName != TypeFullNames.ILambdaContext && + !TypeFullNames.ALBRequests.Contains(parameter.Type.FullName) && + !parameter.Attributes.Any(att => + att.Type.FullName == TypeFullNames.FromServiceAttribute || + att.Type.FullName == TypeFullNames.ALBFromQueryAttribute || + att.Type.FullName == TypeFullNames.ALBFromHeaderAttribute || + att.Type.FullName == TypeFullNames.ALBFromBodyAttribute || + att.Type.FullName == TypeFullNames.FromRouteAttribute)) // FromRoute already has its own error + { + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.AlbUnmappedParameter, methodLocation, parameter.Name)); + } + } + } + private static bool ReportDiagnostics(DiagnosticReporter diagnosticReporter, List diagnostics) { var isValid = true; diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs index a59aaf6d4..c384b7b48 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; @@ -203,6 +204,7 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la { var currentSyncedEvents = new List(); var currentSyncedEventProperties = new Dictionary>(); + var currentAlbResources = new List(); foreach (var attributeModel in lambdaFunction.Attributes) { @@ -221,10 +223,15 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la eventName = ProcessSqsAttribute(lambdaFunction, sqsAttributeModel.Data, currentSyncedEventProperties); currentSyncedEvents.Add(eventName); break; + case AttributeModel albAttributeModel: + var albResourceNames = ProcessAlbApiAttribute(lambdaFunction, albAttributeModel.Data); + currentAlbResources.AddRange(albResourceNames); + break; } } SynchronizeEventsAndProperties(currentSyncedEvents, currentSyncedEventProperties, lambdaFunction); + SynchronizeAlbResources(currentAlbResources, lambdaFunction); } /// @@ -597,8 +604,237 @@ 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. /// + /// List of the three generated CloudFormation resource names for tracking/synchronization. + private List 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"); + + // MultiValueHeaders must be set via TargetGroupAttributes, not as a top-level property. + // The CFN property "MultiValueHeadersEnabled" does not exist on AWS::ElasticLoadBalancingV2::TargetGroup. + if (att.MultiValueHeaders) + { + _templateWriter.SetToken($"{tgPath}.Properties.TargetGroupAttributes", + new List> + { + new Dictionary + { + { "Key", "lambda.multi_value_headers.enabled" }, + { "Value", "true" } + } + }, TokenType.List); + } + else + { + _templateWriter.RemoveToken($"{tgPath}.Properties.TargetGroupAttributes"); + } + + _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); + + // Warn if the referenced resource/parameter doesn't exist in the template + if (!_templateWriter.Exists($"Resources.{refName}") && !_templateWriter.Exists($"{PARAMETERS}.{refName}")) + { + _diagnosticReporter.Report(Diagnostic.Create(DiagnosticDescriptors.AlbListenerReferenceNotFound, Location.None, 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" }, + { "PathPatternConfig", new Dictionary + { + { "Values", new List { att.PathPattern } } + } + } + } + }; + if (!string.IsNullOrEmpty(att.HostHeader)) + { + conditions.Add(new Dictionary + { + { "Field", "host-header" }, + { "HostHeaderConfig", new Dictionary + { + { "Values", new List { att.HostHeader } } + } + } + }); + } + if (!string.IsNullOrEmpty(att.HttpMethod)) + { + conditions.Add(new Dictionary + { + { "Field", "http-request-method" }, + { "HttpRequestMethodConfig", new Dictionary + { + { "Values", new List { att.HttpMethod.ToUpper() } } + } + } + }); + } + if (!string.IsNullOrEmpty(att.HttpHeaderConditionName) && att.HttpHeaderConditionValues != null && att.HttpHeaderConditionValues.Length > 0) + { + conditions.Add(new Dictionary + { + { "Field", "http-header" }, + { "HttpHeaderConfig", new Dictionary + { + { "HttpHeaderName", att.HttpHeaderConditionName }, + { "Values", att.HttpHeaderConditionValues.ToList() } + } + } + }); + } + if (att.QueryStringConditions != null && att.QueryStringConditions.Length > 0) + { + var keyValuePairs = new List>(); + foreach (var entry in att.QueryStringConditions) + { + var separatorIndex = entry.IndexOf('='); + if (separatorIndex >= 0) + { + var key = entry.Substring(0, separatorIndex); + var value = entry.Substring(separatorIndex + 1); + var kvp = new Dictionary(); + if (!string.IsNullOrEmpty(key)) + { + kvp["Key"] = key; + } + kvp["Value"] = value; + keyValuePairs.Add(kvp); + } + } + if (keyValuePairs.Any()) + { + conditions.Add(new Dictionary + { + { "Field", "query-string" }, + { "QueryStringConfig", new Dictionary + { + { "Values", keyValuePairs } + } + } + }); + } + } + if (att.SourceIpConditions != null && att.SourceIpConditions.Length > 0) + { + conditions.Add(new Dictionary + { + { "Field", "source-ip" }, + { "SourceIpConfig", new Dictionary + { + { "Values", att.SourceIpConditions.ToList() } + } + } + }); + } + _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); + } + + return new List { permissionName, targetGroupName, listenerRuleName }; + } + + /// + /// Synchronizes ALB resources for a given Lambda function. ALB resources (Permission, TargetGroup, ListenerRule) + /// are standalone top-level CloudFormation resources, so they need separate tracking from SAM events. + /// Previously generated ALB resources that are no longer present in the current compilation are removed. + /// + private void SynchronizeAlbResources(List currentAlbResources, ILambdaFunctionSerializable lambdaFunction) + { + var syncedAlbResourcesPath = $"Resources.{lambdaFunction.ResourceName}.Metadata.SyncedAlbResources"; + + // Get previously synced ALB resources + var previousAlbResources = _templateWriter.GetToken>(syncedAlbResourcesPath, new List()); + + // Remove orphaned ALB resources + var orphanedAlbResources = previousAlbResources.Except(currentAlbResources).ToList(); + foreach (var resourceName in orphanedAlbResources) + { + var resourcePath = $"Resources.{resourceName}"; + // Only remove if it was created by this tool + if (_templateWriter.Exists(resourcePath) && + string.Equals(_templateWriter.GetToken($"{resourcePath}.Metadata.Tool", string.Empty), CREATION_TOOL, StringComparison.Ordinal)) + { + _templateWriter.RemoveToken(resourcePath); + } + } + + // Update synced ALB resources in the template metadata + _templateWriter.RemoveToken(syncedAlbResourcesPath); + if (currentAlbResources.Any()) + _templateWriter.SetToken(syncedAlbResourcesPath, currentAlbResources, TokenType.List); + } /// /// Writes the default values for the Lambda function's metadata and properties. 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..37ec15c12 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/ALB/ALBApiAttribute.cs @@ -0,0 +1,185 @@ +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; } + + /// + /// Optional HTTP header name for an http-header listener rule condition (e.g., "X-Environment", "User-Agent"). + /// Must be used together with . + /// The header name is not case-sensitive. + /// + public string HttpHeaderConditionName { get; set; } + + /// + /// Optional HTTP header values for an http-header listener rule condition (e.g., new[] { "dev", "*Chrome*" }). + /// Supports wildcards (* and ?). Must be used together with . + /// Up to 3 match evaluations per condition. + /// + public string[] HttpHeaderConditionValues { get; set; } + + /// + /// Optional query string key/value pairs for a query-string listener rule condition. + /// Format: "key=value" pairs. Use "=value" (empty key) to match any key with that value. + /// Supports wildcards (* and ?). + /// Example: new[] { "version=v1", "=*example*" } + /// + public string[] QueryStringConditions { get; set; } + + /// + /// Optional source IP CIDR blocks for a source-ip listener rule condition. + /// Example: new[] { "192.0.2.0/24", "198.51.100.10/32" } + /// Supports both IPv4 and IPv6 addresses in CIDR format. + /// + public string[] SourceIpConditions { 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)."); + } + } + + // Validate http-header condition: both name and values must be set together + if (!string.IsNullOrEmpty(HttpHeaderConditionName) && (HttpHeaderConditionValues == null || HttpHeaderConditionValues.Length == 0)) + { + validationErrors.Add($"{nameof(HttpHeaderConditionName)} is set to '{HttpHeaderConditionName}' but {nameof(HttpHeaderConditionValues)} is not set. Both must be specified together."); + } + if ((HttpHeaderConditionValues != null && HttpHeaderConditionValues.Length > 0) && string.IsNullOrEmpty(HttpHeaderConditionName)) + { + validationErrors.Add($"{nameof(HttpHeaderConditionValues)} is set but {nameof(HttpHeaderConditionName)} is not set. Both must be specified together."); + } + + return validationErrors; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations/ALB/FromBodyAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/ALB/FromBodyAttribute.cs new file mode 100644 index 000000000..335d6d04d --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/ALB/FromBodyAttribute.cs @@ -0,0 +1,15 @@ +using System; + +namespace Amazon.Lambda.Annotations.ALB +{ + /// + /// Maps this parameter to the HTTP request body from the ALB request + /// + /// + /// If the parameter is a complex type then the request body will be assumed to be JSON and deserialized into the type. + /// + [AttributeUsage(AttributeTargets.Parameter)] + public class FromBodyAttribute : Attribute + { + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations/ALB/FromHeaderAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/ALB/FromHeaderAttribute.cs new file mode 100644 index 000000000..54e8c1385 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/ALB/FromHeaderAttribute.cs @@ -0,0 +1,16 @@ +using System; + +namespace Amazon.Lambda.Annotations.ALB +{ + /// + /// Maps this parameter to an HTTP header value from the ALB request + /// + [AttributeUsage(AttributeTargets.Parameter)] + public class FromHeaderAttribute : Attribute, INamedAttribute + { + /// + /// Name of the header. If not specified, the parameter name is used. + /// + public string Name { get; set; } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations/ALB/FromQueryAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/ALB/FromQueryAttribute.cs new file mode 100644 index 000000000..0455f8c63 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/ALB/FromQueryAttribute.cs @@ -0,0 +1,16 @@ +using System; + +namespace Amazon.Lambda.Annotations.ALB +{ + /// + /// Maps this parameter to a query string parameter from the ALB request + /// + [AttributeUsage(AttributeTargets.Parameter)] + public class FromQueryAttribute : Attribute, INamedAttribute + { + /// + /// Name of the query string parameter. If not specified, the parameter name is used. + /// + public string Name { get; set; } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations/README.md b/Libraries/src/Amazon.Lambda.Annotations/README.md index 75dfaac23..18f604718 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/README.md +++ b/Libraries/src/Amazon.Lambda.Annotations/README.md @@ -19,6 +19,7 @@ Topics: - [Amazon API Gateway example](#amazon-api-gateway-example) - [Amazon S3 example](#amazon-s3-example) - [SQS Event Example](#sqs-event-example) + - [Application Load Balancer (ALB) Example](#application-load-balancer-alb-example) - [Custom Lambda Authorizer Example](#custom-lambda-authorizer-example) - [HTTP API Authorizer](#http-api-authorizer) - [REST API Authorizer](#rest-api-authorizer) @@ -852,6 +853,226 @@ The following SQS event source mapping will be generated for the `SQSMessageHand } ``` +## Application Load Balancer (ALB) Example + +This example shows how to use the `ALBApi` attribute to configure a Lambda function as a target behind an [Application Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html). Unlike API Gateway event attributes that map to SAM event types, the ALB integration generates standalone CloudFormation resources — a `TargetGroup`, a `ListenerRule`, and a `Lambda::Permission` — to wire the Lambda function to an existing ALB listener. + +The `ALBApi` attribute contains the following properties: + +| Property | Type | Required | Default | Description | +|---|---|---|---|---| +| `ListenerArn` | `string` | Yes | — | The ARN of the existing ALB listener, or a `@ResourceName` reference to a listener resource defined in the CloudFormation template. | +| `PathPattern` | `string` | Yes | — | The path pattern condition for the listener rule (e.g., `"/api/orders/*"`). Supports wildcard characters `*` and `?`. | +| `Priority` | `int` | Yes | — | The listener rule priority (1–50000). Lower numbers are evaluated first. Must be unique per listener. | +| `MultiValueHeaders` | `bool` | No | `false` | When `true`, enables multi-value headers on the target group. The function should then use `MultiValueHeaders` and `MultiValueQueryStringParameters` on request/response objects. | +| `HostHeader` | `string` | No | `null` | Optional host header condition (e.g., `"api.example.com"`). | +| `HttpMethod` | `string` | No | `null` | Optional HTTP method condition (e.g., `"GET"`, `"POST"`). Leave null to match all methods. | +| `ResourceName` | `string` | No | `"{LambdaResourceName}ALB"` | Custom CloudFormation resource name prefix for the generated resources. Must be alphanumeric. | + +The `ALBApi` attribute must be applied to a Lambda method along with the `LambdaFunction` attribute. + +The Lambda method must conform to the following rules when tagged with the `ALBApi` attribute: + +1. It must have at least 1 argument and can have at most 2 arguments. + - The first argument is required and must be of type `ApplicationLoadBalancerRequest` defined in the [Amazon.Lambda.ApplicationLoadBalancerEvents](https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.ApplicationLoadBalancerEvents) package. + - The second argument is optional and must be of type `ILambdaContext`. +2. The method return type must be `ApplicationLoadBalancerResponse` or `Task`. + +### Prerequisites + +Your CloudFormation template must include an existing ALB and listener. The `ALBApi` attribute references the listener — it does **not** create the ALB or listener for you. You can define them in the same template or reference one that already exists via its ARN. + +### Basic Example + +This example creates a simple hello endpoint behind an ALB listener that is defined elsewhere in the template: + +```csharp +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.ALB; +using Amazon.Lambda.ApplicationLoadBalancerEvents; +using Amazon.Lambda.Core; +using System.Collections.Generic; + +public class ALBFunctions +{ + [LambdaFunction(ResourceName = "ALBHello", MemorySize = 256, Timeout = 15)] + [ALBApi("@ALBTestListener", "/hello", 1)] + public ApplicationLoadBalancerResponse Hello(ApplicationLoadBalancerRequest request, ILambdaContext context) + { + context.Logger.LogInformation($"Hello endpoint hit. Path: {request.Path}"); + + return new ApplicationLoadBalancerResponse + { + StatusCode = 200, + StatusDescription = "200 OK", + IsBase64Encoded = false, + Headers = new Dictionary + { + { "Content-Type", "application/json" } + }, + Body = $"{{\"message\": \"Hello from ALB Lambda!\", \"path\": \"{request.Path}\"}}" + }; + } +} +``` + +In the example above, `@ALBTestListener` references a listener resource called `ALBTestListener` defined in the same CloudFormation template. The `@` prefix tells the source generator to use a `Ref` intrinsic function instead of a literal ARN string. + +### Using a Literal Listener ARN + +If you want to reference an ALB listener in a different stack or one that was created outside of CloudFormation, use the full ARN: + +```csharp +[LambdaFunction(ResourceName = "ALBHandler")] +[ALBApi("arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc123/def456", "/api/*", 10)] +public ApplicationLoadBalancerResponse HandleRequest(ApplicationLoadBalancerRequest request, ILambdaContext context) +{ + return new ApplicationLoadBalancerResponse + { + StatusCode = 200, + Headers = new Dictionary { { "Content-Type", "application/json" } }, + Body = "{\"status\": \"ok\"}" + }; +} +``` + +### Advanced Example with All Options + +This example shows all optional properties including host header filtering, HTTP method filtering, multi-value headers, and a custom resource name: + +```csharp +[LambdaFunction(ResourceName = "ALBOrders")] +[ALBApi("@MyListener", "/api/orders/*", 5, + MultiValueHeaders = true, + HostHeader = "api.example.com", + HttpMethod = "POST", + ResourceName = "OrdersALB")] +public ApplicationLoadBalancerResponse CreateOrder(ApplicationLoadBalancerRequest request, ILambdaContext context) +{ + // When MultiValueHeaders is true, use MultiValueHeaders and MultiValueQueryStringParameters + var contentTypes = request.MultiValueHeaders?["content-type"]; + + return new ApplicationLoadBalancerResponse + { + StatusCode = 201, + StatusDescription = "201 Created", + MultiValueHeaders = new Dictionary> + { + { "Content-Type", new List { "application/json" } }, + { "X-Custom-Header", new List { "value1", "value2" } } + }, + Body = "{\"orderId\": \"12345\"}" + }; +} +``` + +### Generated CloudFormation Resources + +For each `ALBApi` attribute, the source generator creates three CloudFormation resources. Here is an example of the generated template for the basic hello endpoint: + +```json +"ALBHello": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "Runtime": "dotnet6", + "CodeUri": ".", + "MemorySize": 256, + "Timeout": 15, + "Policies": ["AWSLambdaBasicExecutionRole"], + "PackageType": "Zip", + "Handler": "MyProject::MyNamespace.ALBFunctions_Hello_Generated::Hello" + } +}, +"ALBHelloALBPermission": { + "Type": "AWS::Lambda::Permission", + "Metadata": { "Tool": "Amazon.Lambda.Annotations" }, + "Properties": { + "FunctionName": { "Fn::GetAtt": ["ALBHello", "Arn"] }, + "Action": "lambda:InvokeFunction", + "Principal": "elasticloadbalancing.amazonaws.com" + } +}, +"ALBHelloALBTargetGroup": { + "Type": "AWS::ElasticLoadBalancingV2::TargetGroup", + "Metadata": { "Tool": "Amazon.Lambda.Annotations" }, + "DependsOn": "ALBHelloALBPermission", + "Properties": { + "TargetType": "lambda", + "Targets": [ + { "Id": { "Fn::GetAtt": ["ALBHello", "Arn"] } } + ] + } +}, +"ALBHelloALBListenerRule": { + "Type": "AWS::ElasticLoadBalancingV2::ListenerRule", + "Metadata": { "Tool": "Amazon.Lambda.Annotations" }, + "Properties": { + "ListenerArn": { "Ref": "ALBTestListener" }, + "Priority": 1, + "Conditions": [ + { "Field": "path-pattern", "Values": ["/hello"] } + ], + "Actions": [ + { "Type": "forward", "TargetGroupArn": { "Ref": "ALBHelloALBTargetGroup" } } + ] + } +} +``` + +When `MultiValueHeaders` is set to `true`, the target group will include a `TargetGroupAttributes` section: + +```json +"TargetGroupAttributes": [ + { "Key": "lambda.multi_value_headers.enabled", "Value": "true" } +] +``` + +When `HostHeader` or `HttpMethod` are specified, additional conditions are added to the listener rule: + +```json +"Conditions": [ + { "Field": "path-pattern", "Values": ["/api/orders/*"] }, + { "Field": "host-header", "Values": ["api.example.com"] }, + { "Field": "http-request-method", "Values": ["POST"] } +] +``` + +### Setting Up the ALB in the Template + +The `ALBApi` attribute requires an existing ALB listener. Here is a minimal example of the infrastructure resources you would add to your `serverless.template`: + +```json +{ + "MyVPC": { "Type": "AWS::EC2::VPC", "Properties": { "CidrBlock": "10.0.0.0/16" } }, + "MySubnet1": { "Type": "AWS::EC2::Subnet", "Properties": { "VpcId": { "Ref": "MyVPC" }, "CidrBlock": "10.0.1.0/24" } }, + "MySubnet2": { "Type": "AWS::EC2::Subnet", "Properties": { "VpcId": { "Ref": "MyVPC" }, "CidrBlock": "10.0.2.0/24" } }, + "MySecurityGroup": { "Type": "AWS::EC2::SecurityGroup", "Properties": { "GroupDescription": "ALB SG", "VpcId": { "Ref": "MyVPC" } } }, + "MyALB": { + "Type": "AWS::ElasticLoadBalancingV2::LoadBalancer", + "Properties": { + "Type": "application", + "Scheme": "internet-facing", + "Subnets": [{ "Ref": "MySubnet1" }, { "Ref": "MySubnet2" }], + "SecurityGroups": [{ "Ref": "MySecurityGroup" }] + } + }, + "MyListener": { + "Type": "AWS::ElasticLoadBalancingV2::Listener", + "Properties": { + "LoadBalancerArn": { "Ref": "MyALB" }, + "Port": 80, + "Protocol": "HTTP", + "DefaultActions": [{ "Type": "fixed-response", "FixedResponseConfig": { "StatusCode": "404" } }] + } + } +} +``` + +Then your Lambda function references `@MyListener` in the `ALBApi` attribute. + ## Custom Lambda Authorizer Example Lambda Annotations supports defining custom Lambda authorizers using attributes. Custom authorizers let you control access to your API Gateway endpoints by running a Lambda function that validates tokens or request parameters before the target function is invoked. The source generator automatically wires up the authorizer resources and references in the CloudFormation template. @@ -1198,7 +1419,9 @@ parameter to the `LambdaFunction` must be the event object and the event source * RestApiAuthorizer * Marks a Lambda function as a REST API (API Gateway V1) custom authorizer. The authorizer name is automatically derived from the method name. Other functions reference it via `RestApi.Authorizer` using `nameof()`. Use the `Type` property to choose between `Token` and `Request` authorizer types. * SQSEvent - * Sets up event source mapping between the Lambda function and SQS queues. The SQS queue ARN is required to be set on the attribute. If users want to pass a reference to an existing SQS queue resource defined in their CloudFormation template, they can pass the SQS queue resource name prefixed with the '@' symbol. + * Sets up event source mapping between the Lambda function and SQS queues. The SQS queue ARN is required to be set on the attribute. If users want to pass a reference to an existing SQS queue resource defined in their CloudFormation template, they can pass the SQS queue resource name prefixed with the '@' symbol. +* ALBApi + * Configures the Lambda function to be called from an Application Load Balancer. The listener ARN (or `@ResourceName` template reference), path pattern, and priority are required. The source generator creates standalone CloudFormation resources (TargetGroup, ListenerRule, Lambda Permission) rather than SAM event types. ### Parameter Attributes @@ -1277,3 +1500,5 @@ The content type is determined using the following rules. ## Project References If API Gateway event attributes, such as `RestAPI` or `HttpAPI`, are being used then a package reference to `Amazon.Lambda.APIGatewayEvents` must be added to the project, otherwise the project will not compile. We do not include it by default in order to keep the `Amazon.Lambda.Annotations` library lightweight. + +Similarly, if the `ALBApi` attribute is being used then a package reference to `Amazon.Lambda.ApplicationLoadBalancerEvents` must be added to the project. This provides the `ApplicationLoadBalancerRequest` and `ApplicationLoadBalancerResponse` types used by ALB Lambda functions. 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..37d607a04 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/ALBApiAttributeTests.cs @@ -0,0 +1,492 @@ +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); + } + + // ===== HTTP Header Condition Tests ===== + + [Fact] + public void HttpHeaderCondition_DefaultValues_AreNull() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1); + + Assert.Null(attr.HttpHeaderConditionName); + Assert.Null(attr.HttpHeaderConditionValues); + } + + [Fact] + public void HttpHeaderCondition_BothSet_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + HttpHeaderConditionName = "X-Environment", + HttpHeaderConditionValues = new[] { "dev", "staging" } + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + Assert.Equal("X-Environment", attr.HttpHeaderConditionName); + Assert.Equal(2, attr.HttpHeaderConditionValues.Length); + Assert.Equal("dev", attr.HttpHeaderConditionValues[0]); + Assert.Equal("staging", attr.HttpHeaderConditionValues[1]); + } + + [Fact] + public void HttpHeaderCondition_NameSetWithoutValues_ReturnsError() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + HttpHeaderConditionName = "X-Environment" + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("HttpHeaderConditionName", errors[0]); + Assert.Contains("HttpHeaderConditionValues", errors[0]); + } + + [Fact] + public void HttpHeaderCondition_ValuesSetWithoutName_ReturnsError() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + HttpHeaderConditionValues = new[] { "dev" } + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("HttpHeaderConditionValues", errors[0]); + Assert.Contains("HttpHeaderConditionName", errors[0]); + } + + [Fact] + public void HttpHeaderCondition_NameSetWithEmptyValues_ReturnsError() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + HttpHeaderConditionName = "User-Agent", + HttpHeaderConditionValues = new string[0] + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("HttpHeaderConditionName", errors[0]); + } + + [Fact] + public void HttpHeaderCondition_WithWildcards_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + HttpHeaderConditionName = "User-Agent", + HttpHeaderConditionValues = new[] { "*Chrome*", "*Safari*" } + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + // ===== Query String Condition Tests ===== + + [Fact] + public void QueryStringConditions_DefaultValue_IsNull() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1); + Assert.Null(attr.QueryStringConditions); + } + + [Fact] + public void QueryStringConditions_WithKeyValuePairs_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + QueryStringConditions = new[] { "version=v1", "=*example*" } + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + Assert.Equal(2, attr.QueryStringConditions.Length); + Assert.Equal("version=v1", attr.QueryStringConditions[0]); + Assert.Equal("=*example*", attr.QueryStringConditions[1]); + } + + [Fact] + public void QueryStringConditions_WithSingleEntry_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + QueryStringConditions = new[] { "env=prod" } + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + // ===== Source IP Condition Tests ===== + + [Fact] + public void SourceIpConditions_DefaultValue_IsNull() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1); + Assert.Null(attr.SourceIpConditions); + } + + [Fact] + public void SourceIpConditions_WithCidrBlocks_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + SourceIpConditions = new[] { "192.0.2.0/24", "198.51.100.10/32" } + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + Assert.Equal(2, attr.SourceIpConditions.Length); + } + + [Fact] + public void SourceIpConditions_WithIPv6_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + SourceIpConditions = new[] { "2001:db8::/32" } + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + // ===== Combined Condition Tests ===== + + [Fact] + public void AllConditions_CanBeSetTogether_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + HostHeader = "api.example.com", + HttpMethod = "POST", + HttpHeaderConditionName = "X-Environment", + HttpHeaderConditionValues = new[] { "dev" }, + QueryStringConditions = new[] { "version=v1" }, + SourceIpConditions = new[] { "10.0.0.0/8" } + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + } +} 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..45bbc571d --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/ALBApiModelTests.cs @@ -0,0 +1,269 @@ +using Amazon.Lambda.Annotations.SourceGenerator; +using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics; +using Amazon.Lambda.Annotations.SourceGenerator.Extensions; +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 Annotations.ALB.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 Annotations.ALB.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); + } + + [Fact] + public void ParameterListExtension_ALBRequest_IsNotConvertible() + { + // ApplicationLoadBalancerRequest parameters should be treated as pass-through + var parameters = new List + { + new ParameterModel + { + Name = "request", + Type = new TypeModel { FullName = TypeFullNames.ApplicationLoadBalancerRequest }, + Attributes = new List() + } + }; + + Assert.False(parameters.HasConvertibleParameter()); + } + + [Fact] + public void ParameterListExtension_FromQuery_IsConvertible() + { + // A [FromQuery] string parameter should be convertible + var parameters = new List + { + new ParameterModel + { + Name = "name", + Type = new TypeModel { FullName = "System.String" }, + Attributes = new List + { + new AttributeModel + { + Data = new Annotations.APIGateway.FromQueryAttribute(), + Type = new TypeModel { FullName = TypeFullNames.FromQueryAttribute } + } + } + } + }; + + Assert.True(parameters.HasConvertibleParameter()); + } + + [Fact] + public void ParameterListExtension_ILambdaContext_IsNotConvertible() + { + var parameters = new List + { + new ParameterModel + { + Name = "context", + Type = new TypeModel { FullName = TypeFullNames.ILambdaContext }, + Attributes = new List() + } + }; + + Assert.False(parameters.HasConvertibleParameter()); + } + + [Fact] + public void ParameterListExtension_FromBodyString_IsNotConvertible() + { + // A [FromBody] string parameter should NOT be convertible (string body is pass-through) + var parameters = new List + { + new ParameterModel + { + Name = "body", + Type = new TypeModel { FullName = "string" }, + Attributes = new List + { + new AttributeModel + { + Data = new Annotations.APIGateway.FromBodyAttribute(), + Type = new TypeModel { FullName = TypeFullNames.FromBodyAttribute } + } + } + } + }; + + Assert.False(parameters.HasConvertibleParameter()); + } + + [Fact] + public void DiagnosticDescriptors_FromRouteNotSupportedOnAlb_Exists() + { + Assert.Equal("AWSLambda0134", DiagnosticDescriptors.FromRouteNotSupportedOnAlb.Id); + Assert.Equal(Microsoft.CodeAnalysis.DiagnosticSeverity.Error, DiagnosticDescriptors.FromRouteNotSupportedOnAlb.DefaultSeverity); + } + + [Fact] + public void DiagnosticDescriptors_AlbUnmappedParameter_Exists() + { + Assert.Equal("AWSLambda0135", DiagnosticDescriptors.AlbUnmappedParameter.Id); + Assert.Equal(Microsoft.CodeAnalysis.DiagnosticSeverity.Error, DiagnosticDescriptors.AlbUnmappedParameter.DefaultSeverity); + } + + [Fact] + public void ALBFromQuery_ParameterName_DefaultsToParameterName() + { + // When Name is not set, ALB FromQueryAttribute should default to parameter name + var attr = new Annotations.ALB.FromQueryAttribute(); + Assert.Null(attr.Name); + } + + [Fact] + public void ALBFromQuery_ParameterName_UsesExplicitName() + { + var attr = new Annotations.ALB.FromQueryAttribute { Name = "custom_name" }; + Assert.Equal("custom_name", attr.Name); + } + + [Fact] + public void ALBFromHeader_ParameterName_UsesExplicitName() + { + var attr = new Annotations.ALB.FromHeaderAttribute { Name = "X-Custom-Header" }; + Assert.Equal("X-Custom-Header", attr.Name); + } + } +} 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 @@ +