diff --git a/.autover/changes/function-url-annotations-support.json b/.autover/changes/function-url-annotations-support.json new file mode 100644 index 000000000..24ad4e288 --- /dev/null +++ b/.autover/changes/function-url-annotations-support.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.Annotations", + "Type": "Minor", + "ChangelogMessages": [ + "Added [FunctionUrl] attribute for configuring Lambda functions with Function URL endpoints, including optional CORS support" + ] + } + ] +} 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 0d1067bb6..d8715c047 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using System; using Amazon.Lambda.Annotations.ALB; using Amazon.Lambda.Annotations.APIGateway; @@ -101,6 +104,15 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext Type = TypeModelBuilder.Build(att.AttributeClass, context) }; } + else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.FunctionUrlAttribute), SymbolEqualityComparer.Default)) + { + var data = FunctionUrlAttributeBuilder.Build(att); + model = new AttributeModel + { + Data = data, + Type = TypeModelBuilder.Build(att.AttributeClass, context) + }; + } else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.HttpApiAuthorizerAttribute), SymbolEqualityComparer.Default)) { var data = HttpApiAuthorizerAttributeBuilder.Build(att); @@ -166,4 +178,4 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext return model; } } -} \ No newline at end of file +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs new file mode 100644 index 000000000..48bb69ea8 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs @@ -0,0 +1,48 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Linq; +using Amazon.Lambda.Annotations.APIGateway; +using Microsoft.CodeAnalysis; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes +{ + public static class FunctionUrlAttributeBuilder + { + public static FunctionUrlAttribute Build(AttributeData att) + { + var authType = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AuthType").Value.Value; + + var data = new FunctionUrlAttribute + { + AuthType = authType == null ? FunctionUrlAuthType.NONE : (FunctionUrlAuthType)authType + }; + + var allowOrigins = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AllowOrigins").Value; + if (!allowOrigins.IsNull) + data.AllowOrigins = allowOrigins.Values.Select(v => v.Value as string).ToArray(); + + var allowMethods = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AllowMethods").Value; + if (!allowMethods.IsNull) + data.AllowMethods = allowMethods.Values.Select(v => (LambdaHttpMethod)(int)v.Value).ToArray(); + + var allowHeaders = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AllowHeaders").Value; + if (!allowHeaders.IsNull) + data.AllowHeaders = allowHeaders.Values.Select(v => v.Value as string).ToArray(); + + var exposeHeaders = att.NamedArguments.FirstOrDefault(arg => arg.Key == "ExposeHeaders").Value; + if (!exposeHeaders.IsNull) + data.ExposeHeaders = exposeHeaders.Values.Select(v => v.Value as string).ToArray(); + + var allowCredentials = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AllowCredentials").Value.Value; + if (allowCredentials != null) + data.AllowCredentials = (bool)allowCredentials; + + var maxAge = att.NamedArguments.FirstOrDefault(arg => arg.Key == "MaxAge").Value.Value; + if (maxAge != null) + data.MaxAge = (int)maxAge; + + return data; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs index 3dfc51799..d3c1f7fd0 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using System; using System.Collections.Generic; using System.Linq; @@ -18,7 +21,8 @@ public static HashSet Build(IMethodSymbol lambdaMethodSymbol, foreach (var attribute in lambdaMethodSymbol.GetAttributes()) { if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.RestApiAttribute - || attribute.AttributeClass.ToDisplayString() == TypeFullNames.HttpApiAttribute) + || attribute.AttributeClass.ToDisplayString() == TypeFullNames.HttpApiAttribute + || attribute.AttributeClass.ToDisplayString() == TypeFullNames.FunctionUrlAttribute) { events.Add(EventType.API); } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs index 2dcd58fe0..e3c6a020e 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using System; using System.Collections.Generic; using System.Linq; @@ -144,6 +147,14 @@ private static TypeModel BuildResponseType(IMethodSymbol lambdaMethodSymbol, context.Compilation.GetTypeByMetadataName(TypeFullNames.ApplicationLoadBalancerResponse); return TypeModelBuilder.Build(symbol, context); } + else if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.FunctionUrlAttribute)) + { + // Function URLs use the same payload format as HTTP API v2 + var symbol = lambdaMethodModel.ReturnsVoidOrGenericTask ? + task.Construct(context.Compilation.GetTypeByMetadataName(TypeFullNames.APIGatewayHttpApiV2ProxyResponse)): + context.Compilation.GetTypeByMetadataName(TypeFullNames.APIGatewayHttpApiV2ProxyResponse); + return TypeModelBuilder.Build(symbol, context); + } else { return lambdaMethodModel.ReturnType; @@ -304,6 +315,20 @@ private static IList BuildParameters(IMethodSymbol lambdaMethodS parameters.Add(requestParameter); parameters.Add(contextParameter); } + else if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.FunctionUrlAttribute)) + { + // Function URLs use the same payload format as HTTP API v2 + var symbol = context.Compilation.GetTypeByMetadataName(TypeFullNames.APIGatewayHttpApiV2ProxyRequest); + var type = TypeModelBuilder.Build(symbol, context); + var requestParameter = new ParameterModel + { + Name = "__request__", + Type = type, + Documentation = "The Function URL request object that will be processed by the Lambda function handler." + }; + parameters.Add(requestParameter); + parameters.Add(contextParameter); + } else { // Lambda method with no event attribute are plain lambda functions, therefore, generated method will have diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs index 2091d6c94..230525edd 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs @@ -1,4 +1,7 @@ -using System; +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; using System.Collections.Generic; using System.Linq; using Amazon.Lambda.Annotations.SourceGenerator.FileIO; @@ -21,6 +24,7 @@ internal class SyntaxReceiver : ISyntaxContextReceiver { "RestApiAuthorizerAttribute", "RestApiAuthorizer" }, { "HttpApiAttribute", "HttpApi" }, { "RestApiAttribute", "RestApi" }, + { "FunctionUrlAttribute", "FunctionUrl" }, { "SQSEventAttribute", "SQSEvent" }, { "ALBApiAttribute", "ALBApi" }, { "S3EventAttribute", "S3Event" } @@ -122,4 +126,4 @@ public void OnVisitSyntaxNode(GeneratorSyntaxContext context) } } } -} \ No newline at end of file +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs index 59fa1d830..4c66c1875 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using System.Collections.Generic; namespace Amazon.Lambda.Annotations.SourceGenerator @@ -34,6 +37,9 @@ public static class TypeFullNames public const string FromRouteAttribute = "Amazon.Lambda.Annotations.APIGateway.FromRouteAttribute"; public const string FromCustomAuthorizerAttribute = "Amazon.Lambda.Annotations.APIGateway.FromCustomAuthorizerAttribute"; + public const string FunctionUrlAttribute = "Amazon.Lambda.Annotations.APIGateway.FunctionUrlAttribute"; + public const string FunctionUrlAuthType = "Amazon.Lambda.Annotations.APIGateway.FunctionUrlAuthType"; + public const string HttpApiAuthorizerAttribute = "Amazon.Lambda.Annotations.APIGateway.HttpApiAuthorizerAttribute"; public const string RestApiAuthorizerAttribute = "Amazon.Lambda.Annotations.APIGateway.RestApiAuthorizerAttribute"; @@ -82,9 +88,10 @@ public static class TypeFullNames { RestApiAttribute, HttpApiAttribute, + FunctionUrlAttribute, SQSEventAttribute, ALBApiAttribute, S3EventAttribute }; } -} \ 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 7e728660a..4ea09acdf 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs @@ -1,4 +1,7 @@ -using Amazon.Lambda.Annotations.ALB; +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.ALB; using Amazon.Lambda.Annotations.S3; using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics; using Amazon.Lambda.Annotations.SourceGenerator.Extensions; @@ -71,6 +74,7 @@ internal static bool ValidateDependencies(GeneratorExecutionContext context, IMe { // Check for references to "Amazon.Lambda.APIGatewayEvents" if the Lambda method is annotated with RestApi, HttpApi, or authorizer attributes. if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.RestApiAttribute) || lambdaMethodSymbol.HasAttribute(context, TypeFullNames.HttpApiAttribute) + || lambdaMethodSymbol.HasAttribute(context, TypeFullNames.FunctionUrlAttribute) || lambdaMethodSymbol.HasAttribute(context, TypeFullNames.HttpApiAuthorizerAttribute) || lambdaMethodSymbol.HasAttribute(context, TypeFullNames.RestApiAuthorizerAttribute)) { if (context.Compilation.ReferencedAssemblyNames.FirstOrDefault(x => x.Name == "Amazon.Lambda.APIGatewayEvents") == null) diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs index a72e3241b..adfa53ae5 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs @@ -1,4 +1,7 @@ -using Amazon.Lambda.Annotations.ALB; +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.ALB; using Amazon.Lambda.Annotations.APIGateway; using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics; using Amazon.Lambda.Annotations.SourceGenerator.FileIO; @@ -206,6 +209,7 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la var currentSyncedEvents = new List(); var currentSyncedEventProperties = new Dictionary>(); var currentAlbResources = new List(); + var hasFunctionUrl = false; foreach (var attributeModel in lambdaFunction.Attributes) { @@ -232,6 +236,23 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la eventName = ProcessS3Attribute(lambdaFunction, s3AttributeModel.Data, currentSyncedEventProperties); currentSyncedEvents.Add(eventName); break; + case AttributeModel functionUrlAttributeModel: + ProcessFunctionUrlAttribute(lambdaFunction, functionUrlAttributeModel.Data); + _templateWriter.SetToken($"Resources.{lambdaFunction.ResourceName}.Metadata.SyncedFunctionUrlConfig", true); + hasFunctionUrl = true; + break; + } + } + + // Remove FunctionUrlConfig only if it was previously created by Annotations (tracked via metadata). + // This preserves any manually-added FunctionUrlConfig that was not created by the source generator. + if (!hasFunctionUrl) + { + var syncedFunctionUrlConfigPath = $"Resources.{lambdaFunction.ResourceName}.Metadata.SyncedFunctionUrlConfig"; + if (_templateWriter.GetToken(syncedFunctionUrlConfigPath, false)) + { + _templateWriter.RemoveToken($"Resources.{lambdaFunction.ResourceName}.Properties.FunctionUrlConfig"); + _templateWriter.RemoveToken(syncedFunctionUrlConfigPath); } } @@ -302,6 +323,50 @@ private string ProcessHttpApiAttribute(ILambdaFunctionSerializable lambdaFunctio return eventName; } + /// + /// Writes the configuration to the serverless template. + /// Unlike HttpApi/RestApi, Function URLs are configured as a property on the function resource + /// rather than as an event source. + /// + private void ProcessFunctionUrlAttribute(ILambdaFunctionSerializable lambdaFunction, FunctionUrlAttribute functionUrlAttribute) + { + var functionUrlConfigPath = $"Resources.{lambdaFunction.ResourceName}.Properties.FunctionUrlConfig"; + _templateWriter.SetToken($"{functionUrlConfigPath}.AuthType", functionUrlAttribute.AuthType.ToString()); + + // Always remove the existing Cors block first to clear any stale properties + // from a previous generation pass, then re-emit only the currently configured values. + var corsPath = $"{functionUrlConfigPath}.Cors"; + _templateWriter.RemoveToken(corsPath); + + var hasCors = functionUrlAttribute.AllowOrigins != null + || functionUrlAttribute.AllowMethods != null + || functionUrlAttribute.AllowHeaders != null + || functionUrlAttribute.ExposeHeaders != null + || functionUrlAttribute.AllowCredentials + || functionUrlAttribute.MaxAge > 0; + + if (hasCors) + { + if (functionUrlAttribute.AllowOrigins != null) + _templateWriter.SetToken($"{corsPath}.AllowOrigins", new List(functionUrlAttribute.AllowOrigins), TokenType.List); + + if (functionUrlAttribute.AllowMethods != null) + _templateWriter.SetToken($"{corsPath}.AllowMethods", functionUrlAttribute.AllowMethods.Select(m => m == LambdaHttpMethod.Any ? "*" : m.ToString().ToUpper()).ToList(), TokenType.List); + + if (functionUrlAttribute.AllowHeaders != null) + _templateWriter.SetToken($"{corsPath}.AllowHeaders", new List(functionUrlAttribute.AllowHeaders), TokenType.List); + + if (functionUrlAttribute.ExposeHeaders != null) + _templateWriter.SetToken($"{corsPath}.ExposeHeaders", new List(functionUrlAttribute.ExposeHeaders), TokenType.List); + + if (functionUrlAttribute.AllowCredentials) + _templateWriter.SetToken($"{corsPath}.AllowCredentials", true); + + if (functionUrlAttribute.MaxAge > 0) + _templateWriter.SetToken($"{corsPath}.MaxAge", functionUrlAttribute.MaxAge); + } + } + /// /// Processes all authorizers and writes them to the serverless template as inline authorizers within the API resources. /// AWS SAM expects authorizers to be defined within the Auth.Authorizers property of AWS::Serverless::HttpApi or AWS::Serverless::Api resources. @@ -1182,4 +1247,4 @@ private void SynchronizeEventsAndProperties(List syncedEvents, Dictionar _templateWriter.SetToken(syncedEventPropertiesPath, syncedEventProperties, TokenType.KeyVal); } } -} \ No newline at end of file +} diff --git a/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAttribute.cs new file mode 100644 index 000000000..a92387762 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAttribute.cs @@ -0,0 +1,51 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; + +namespace Amazon.Lambda.Annotations.APIGateway +{ + /// + /// Configures the Lambda function to be invoked via a Lambda Function URL. + /// + /// + /// Function URLs use the same payload format as HTTP API v2 (APIGatewayHttpApiV2ProxyRequest/Response). + /// + [AttributeUsage(AttributeTargets.Method)] + public class FunctionUrlAttribute : Attribute + { + /// + public FunctionUrlAuthType AuthType { get; set; } = FunctionUrlAuthType.NONE; + + /// + /// The allowed origins for CORS requests. Example: new[] { "https://example.com" } + /// + public string[] AllowOrigins { get; set; } + + /// + /// The allowed HTTP methods for CORS requests. Example: new[] { LambdaHttpMethod.Get, LambdaHttpMethod.Post } + /// + public LambdaHttpMethod[] AllowMethods { get; set; } + + /// + /// The allowed headers for CORS requests. + /// + public string[] AllowHeaders { get; set; } + + /// + /// Whether credentials are included in the CORS request. + /// + public bool AllowCredentials { get; set; } + + /// + /// The expose headers for CORS responses. + /// + public string[] ExposeHeaders { get; set; } + + /// + /// The maximum time in seconds that a browser can cache the CORS preflight response. + /// A value of 0 means the property is not set. + /// + public int MaxAge { get; set; } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAuthType.cs b/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAuthType.cs new file mode 100644 index 000000000..31a1c2397 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAuthType.cs @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace Amazon.Lambda.Annotations.APIGateway +{ + /// + /// The type of authentication for a Lambda Function URL. + /// + public enum FunctionUrlAuthType + { + /// + /// No authentication. Anyone with the Function URL can invoke the function. + /// + NONE, + + /// + /// IAM authentication. Only authenticated IAM users and roles can invoke the function. + /// + AWS_IAM + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations/README.md b/Libraries/src/Amazon.Lambda.Annotations/README.md index d736e06be..45bce6783 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/README.md +++ b/Libraries/src/Amazon.Lambda.Annotations/README.md @@ -20,6 +20,7 @@ Topics: - [Amazon S3 example](#amazon-s3-example) - [SQS Event Example](#sqs-event-example) - [Application Load Balancer (ALB) Example](#application-load-balancer-alb-example) + - [Lambda Function URL Example](#lambda-function-url-example) - [Custom Lambda Authorizer Example](#custom-lambda-authorizer-example) - [HTTP API Authorizer](#http-api-authorizer) - [REST API Authorizer](#rest-api-authorizer) @@ -1073,6 +1074,110 @@ The `ALBApi` attribute requires an existing ALB listener. Here is a minimal exam Then your Lambda function references `@MyListener` in the `ALBApi` attribute. +## Lambda Function URL Example + +[Lambda Function URLs](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html) provide a dedicated HTTPS endpoint for your Lambda function without needing API Gateway or an Application Load Balancer. The `FunctionUrl` attribute configures the function to be invoked via a Function URL. Function URLs use the same payload format as HTTP API v2 (`APIGatewayHttpApiV2ProxyRequest`/`APIGatewayHttpApiV2ProxyResponse`). + +The `FunctionUrl` attribute contains the following properties: + +| Property | Type | Required | Default | Description | +|---|---|---|---|---| +| `AuthType` | `FunctionUrlAuthType` | No | `NONE` | The authentication type: `NONE` (public) or `AWS_IAM` (IAM-authenticated). | +| `AllowOrigins` | `string[]` | No | `null` | Allowed origins for CORS requests (e.g., `new[] { "https://example.com" }`). | +| `AllowMethods` | `LambdaHttpMethod[]` | No | `null` | Allowed HTTP methods for CORS requests, using the `LambdaHttpMethod` enum (e.g., `new[] { LambdaHttpMethod.Get, LambdaHttpMethod.Post }`). | +| `AllowHeaders` | `string[]` | No | `null` | Allowed headers for CORS requests. | +| `ExposeHeaders` | `string[]` | No | `null` | Headers to expose in CORS responses. | +| `AllowCredentials` | `bool` | No | `false` | Whether credentials are included in the CORS request. | +| `MaxAge` | `int` | No | `0` | Maximum time in seconds that a browser can cache the CORS preflight response. `0` means not set. | + +### Basic Example + +A simple function with a public Function URL (no authentication): + +```csharp +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.Core; + +public class Functions +{ + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [FunctionUrl(AuthType = FunctionUrlAuthType.NONE)] + public IHttpResult GetItems([FromQuery] string category, ILambdaContext context) + { + context.Logger.LogLine($"Getting items for category: {category}"); + return HttpResults.Ok(new { items = new[] { "item1", "item2" }, category }); + } +} +``` + +### With IAM Authentication + +Use `FunctionUrlAuthType.AWS_IAM` to require IAM authentication for the Function URL: + +```csharp +[LambdaFunction(PackageType = LambdaPackageType.Image)] +[FunctionUrl(AuthType = FunctionUrlAuthType.AWS_IAM)] +public IHttpResult SecureEndpoint(ILambdaContext context) +{ + return HttpResults.Ok(new { message = "This endpoint requires IAM auth" }); +} +``` + +### With CORS Configuration + +Configure CORS settings directly on the attribute. The `AllowMethods` property uses the type-safe `LambdaHttpMethod` enum, consistent with the `HttpApi` and `RestApi` attributes: + +```csharp +[LambdaFunction(PackageType = LambdaPackageType.Image)] +[FunctionUrl( + AuthType = FunctionUrlAuthType.NONE, + AllowOrigins = new[] { "https://example.com", "https://app.example.com" }, + AllowMethods = new[] { LambdaHttpMethod.Get, LambdaHttpMethod.Post }, + AllowHeaders = new[] { "Content-Type", "Authorization" }, + AllowCredentials = true, + MaxAge = 3600)] +public IHttpResult GetData([FromQuery] string id, ILambdaContext context) +{ + return HttpResults.Ok(new { id, data = "some data" }); +} +``` + +### Generated CloudFormation + +The source generator creates a `FunctionUrlConfig` property on the Lambda function resource (not a SAM event source). Here is an example with CORS: + +```json +"GetDataFunction": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedFunctionUrlConfig": true + }, + "Properties": { + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": ["MyAssembly::MyNamespace.Functions_GetData_Generated::GetData"] + }, + "MemorySize": 512, + "Timeout": 30, + "FunctionUrlConfig": { + "AuthType": "NONE", + "Cors": { + "AllowOrigins": ["https://example.com", "https://app.example.com"], + "AllowMethods": ["GET", "POST"], + "AllowHeaders": ["Content-Type", "Authorization"], + "AllowCredentials": true, + "MaxAge": 3600 + } + } + } +} +``` + +> **Note:** Unlike `HttpApi` and `RestApi` which create SAM event sources, `FunctionUrl` configures the `FunctionUrlConfig` property directly on the function resource. If the `FunctionUrl` attribute is removed from the code, the source generator will automatically clean up the `FunctionUrlConfig` from the CloudFormation template. + ## 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. @@ -1422,6 +1527,8 @@ parameter to the `LambdaFunction` must be the event object and the event source * 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. +* FunctionUrl + * Configures the Lambda function to be invoked via a Lambda Function URL. Supports `AuthType` (`NONE` or `AWS_IAM`) and CORS configuration including `AllowMethods` (using the `LambdaHttpMethod` enum), `AllowOrigins`, `AllowHeaders`, `AllowCredentials`, and `MaxAge`. The source generator writes a `FunctionUrlConfig` property on the function resource rather than a SAM event source. ### Parameter Attributes diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/FunctionUrlExample_GetItems_Generated.g.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/FunctionUrlExample_GetItems_Generated.g.cs new file mode 100644 index 000000000..002cbfe60 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/FunctionUrlExample_GetItems_Generated.g.cs @@ -0,0 +1,98 @@ +// + +using System; +using System.Linq; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using System.IO; +using Amazon.Lambda.Core; +using Amazon.Lambda.Annotations.APIGateway; + +namespace TestServerlessApp +{ + public class FunctionUrlExample_GetItems_Generated + { + private readonly FunctionUrlExample functionUrlExample; + private readonly Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer serializer; + + /// + /// Default constructor. This constructor is used by Lambda to construct the instance. When invoked in a Lambda environment + /// the AWS credentials will come from the IAM role associated with the function and the AWS region will be set to the + /// region the Lambda function is executed in. + /// + public FunctionUrlExample_GetItems_Generated() + { + SetExecutionEnvironment(); + functionUrlExample = new FunctionUrlExample(); + serializer = new Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer(); + } + + /// + /// The generated Lambda function handler for + /// + /// The Function URL request object that will be processed by the Lambda function handler. + /// The ILambdaContext that provides methods for logging and describing the Lambda environment. + /// Result of the Lambda function execution + public System.IO.Stream GetItems(Amazon.Lambda.APIGatewayEvents.APIGatewayHttpApiV2ProxyRequest __request__, Amazon.Lambda.Core.ILambdaContext __context__) + { + var validationErrors = new List(); + + var category = default(string); + if (__request__.QueryStringParameters?.ContainsKey("category") == true) + { + try + { + category = (string)Convert.ChangeType(__request__.QueryStringParameters["category"], typeof(string)); + } + catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException) + { + validationErrors.Add($"Value {__request__.QueryStringParameters["category"]} at 'category' failed to satisfy constraint: {e.Message}"); + } + } + + // return 400 Bad Request if there exists a validation error + if (validationErrors.Any()) + { + var errorResult = new Amazon.Lambda.APIGatewayEvents.APIGatewayHttpApiV2ProxyResponse + { + Body = @$"{{""message"": ""{validationErrors.Count} validation error(s) detected: {string.Join(",", validationErrors)}""}}", + Headers = new Dictionary + { + {"Content-Type", "application/json"}, + {"x-amzn-ErrorType", "ValidationException"} + }, + StatusCode = 400 + }; + var errorStream = new System.IO.MemoryStream(); + serializer.Serialize(errorResult, errorStream); + errorStream.Position = 0; + return errorStream; + } + + var httpResults = functionUrlExample.GetItems(category, __context__); + HttpResultSerializationOptions.ProtocolFormat serializationFormat = HttpResultSerializationOptions.ProtocolFormat.HttpApi; + HttpResultSerializationOptions.ProtocolVersion serializationVersion = HttpResultSerializationOptions.ProtocolVersion.V2; + var serializationOptions = new HttpResultSerializationOptions { Format = serializationFormat, Version = serializationVersion, Serializer = serializer }; + var response = httpResults.Serialize(serializationOptions); + return response; + } + + private static void SetExecutionEnvironment() + { + const string envName = "AWS_EXECUTION_ENV"; + + var envValue = new StringBuilder(); + + // If there is an existing execution environment variable add the annotations package as a suffix. + if(!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(envName))) + { + envValue.Append($"{Environment.GetEnvironmentVariable(envName)}_"); + } + + envValue.Append("lib/amazon-lambda-annotations#{ANNOTATIONS_ASSEMBLY_VERSION}"); + + Environment.SetEnvironmentVariable(envName, envValue.ToString()); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/functionUrlExample.template b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/functionUrlExample.template new file mode 100644 index 000000000..7187fc6ad --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/functionUrlExample.template @@ -0,0 +1,31 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::Serverless-2016-10-31", + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v{ANNOTATIONS_ASSEMBLY_VERSION}).", + "Resources": { + "TestServerlessAppFunctionUrlExampleGetItemsGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedFunctionUrlConfig": true + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestProject::TestServerlessApp.FunctionUrlExample_GetItems_Generated::GetItems" + ] + }, + "FunctionUrlConfig": { + "AuthType": "NONE" + } + } + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs index d14bb893f..ae19b36b0 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using System; using System.IO; using System.Text; @@ -1917,6 +1920,44 @@ public async Task IAuthorizerResultHttpApiTest() Assert.Equal(expectedTemplateContent, actualTemplateContent); } + [Fact] + public async Task FunctionUrlExample() + { + var expectedTemplateContent = await ReadSnapshotContent(Path.Combine("Snapshots", "ServerlessTemplates", "functionUrlExample.template")); + var expectedGetItemsGenerated = await ReadSnapshotContent(Path.Combine("Snapshots", "FunctionUrlExample_GetItems_Generated.g.cs")); + + await new VerifyCS.Test + { + TestState = + { + Sources = + { + (Path.Combine("TestServerlessApp", "FunctionUrlExample.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "FunctionUrlExample.cs"))), + (Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"))), + (Path.Combine("Amazon.Lambda.Annotations", "LambdaStartupAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "LambdaStartupAttribute.cs"))), + (Path.Combine("Amazon.Lambda.Annotations", "APIGateway", "FunctionUrlAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "APIGateway", "FunctionUrlAttribute.cs"))), + (Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"))), + }, + GeneratedSources = + { + ( + typeof(SourceGenerator.Generator), + "FunctionUrlExample_GetItems_Generated.g.cs", + SourceText.From(expectedGetItemsGenerated, Encoding.UTF8, SourceHashAlgorithm.Sha256) + ) + }, + ExpectedDiagnostics = + { + new DiagnosticResult("AWSLambda0103", DiagnosticSeverity.Info).WithArguments("FunctionUrlExample_GetItems_Generated.g.cs", expectedGetItemsGenerated), + new DiagnosticResult("AWSLambda0103", DiagnosticSeverity.Info).WithArguments($"TestServerlessApp{Path.DirectorySeparatorChar}serverless.template", expectedTemplateContent) + } + } + }.RunAsync(); + + var actualTemplateContent = await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "serverless.template")); + Assert.Equal(expectedTemplateContent, actualTemplateContent); + } + public void Dispose() { File.Delete(Path.Combine("TestServerlessApp", "serverless.template")); diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs new file mode 100644 index 000000000..18d5ce3a3 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs @@ -0,0 +1,408 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Generic; +using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.Annotations.SourceGenerator; +using Amazon.Lambda.Annotations.SourceGenerator.Models; +using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; +using Amazon.Lambda.Annotations.SourceGenerator.Writers; +using Xunit; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests.WriterTests +{ + public partial class CloudFormationWriterTests + { + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlWithDefaultAuthType(CloudFormationTemplateFormat templateFormat) + { + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel { Data = new FunctionUrlAttribute() } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal("NONE", templateWriter.GetToken("Resources.TestMethod.Properties.FunctionUrlConfig.AuthType")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlWithIamAuth(CloudFormationTemplateFormat templateFormat) + { + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute { AuthType = FunctionUrlAuthType.AWS_IAM } + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal("AWS_IAM", templateWriter.GetToken("Resources.TestMethod.Properties.FunctionUrlConfig.AuthType")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlDoesNotCreateEventEntry(CloudFormationTemplateFormat templateFormat) + { + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel { Data = new FunctionUrlAttribute() } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.Events")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlWithCors(CloudFormationTemplateFormat templateFormat) + { + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute + { + AuthType = FunctionUrlAuthType.NONE, + AllowOrigins = new[] { "https://example.com" }, + AllowMethods = new[] { LambdaHttpMethod.Get, LambdaHttpMethod.Post }, + AllowHeaders = new[] { "Content-Type", "Authorization" }, + AllowCredentials = true, + MaxAge = 3600 + } + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + var corsPath = "Resources.TestMethod.Properties.FunctionUrlConfig.Cors"; + Assert.Equal(new List { "https://example.com" }, templateWriter.GetToken>($"{corsPath}.AllowOrigins")); + Assert.Equal(new List { "GET", "POST" }, templateWriter.GetToken>($"{corsPath}.AllowMethods")); + Assert.Equal(new List { "Content-Type", "Authorization" }, templateWriter.GetToken>($"{corsPath}.AllowHeaders")); + Assert.True(templateWriter.GetToken($"{corsPath}.AllowCredentials")); + Assert.Equal(3600, templateWriter.GetToken($"{corsPath}.MaxAge")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlWithoutCorsDoesNotEmitCorsBlock(CloudFormationTemplateFormat templateFormat) + { + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute { AuthType = FunctionUrlAuthType.AWS_IAM } + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig.AuthType")); + Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig.Cors")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlCorsRemovedWhenCorsCleared(CloudFormationTemplateFormat templateFormat) + { + // First pass: emit full CORS config + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute + { + AuthType = FunctionUrlAuthType.NONE, + AllowOrigins = new[] { "https://example.com" }, + AllowMethods = new[] { LambdaHttpMethod.Get, LambdaHttpMethod.Post }, + AllowHeaders = new[] { "Content-Type" }, + AllowCredentials = true, + MaxAge = 3600 + } + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + var corsPath = "Resources.TestMethod.Properties.FunctionUrlConfig.Cors"; + Assert.True(templateWriter.Exists(corsPath)); + Assert.Equal(new List { "https://example.com" }, templateWriter.GetToken>($"{corsPath}.AllowOrigins")); + Assert.True(templateWriter.GetToken($"{corsPath}.AllowCredentials")); + Assert.Equal(3600, templateWriter.GetToken($"{corsPath}.MaxAge")); + + // Second pass: clear all CORS properties (AllowOrigins=null, AllowCredentials=false, MaxAge=0) + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute { AuthType = FunctionUrlAuthType.NONE } + } + }; + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal("NONE", templateWriter.GetToken("Resources.TestMethod.Properties.FunctionUrlConfig.AuthType")); + Assert.False(templateWriter.Exists(corsPath)); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlCorsUpdatedBetweenPasses(CloudFormationTemplateFormat templateFormat) + { + // First pass: emit CORS with AllowOrigins and AllowMethods + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute + { + AuthType = FunctionUrlAuthType.NONE, + AllowOrigins = new[] { "https://example.com" }, + AllowMethods = new[] { LambdaHttpMethod.Get, LambdaHttpMethod.Post }, + AllowCredentials = true, + MaxAge = 3600 + } + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + var corsPath = "Resources.TestMethod.Properties.FunctionUrlConfig.Cors"; + Assert.True(templateWriter.Exists($"{corsPath}.AllowOrigins")); + Assert.True(templateWriter.Exists($"{corsPath}.AllowMethods")); + Assert.True(templateWriter.Exists($"{corsPath}.AllowCredentials")); + Assert.True(templateWriter.Exists($"{corsPath}.MaxAge")); + + // Second pass: change to only AllowOrigins with a different value, remove everything else + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute + { + AuthType = FunctionUrlAuthType.NONE, + AllowOrigins = new[] { "https://other.com" } + } + } + }; + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal(new List { "https://other.com" }, templateWriter.GetToken>($"{corsPath}.AllowOrigins")); + Assert.False(templateWriter.Exists($"{corsPath}.AllowMethods")); + Assert.False(templateWriter.Exists($"{corsPath}.AllowCredentials")); + Assert.False(templateWriter.Exists($"{corsPath}.MaxAge")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlConfigRemovedWhenAttributeRemoved(CloudFormationTemplateFormat templateFormat) + { + // First pass: create FunctionUrlConfig + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute { AllowOrigins = new[] { "*" } } + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + + // Second pass: remove the attribute, FunctionUrlConfig should be cleaned up + lambdaFunctionModel.Attributes = new List(); + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void ManualFunctionUrlConfigPreservedWhenNoAttribute(CloudFormationTemplateFormat templateFormat) + { + // Simulate a template where FunctionUrlConfig was manually added (no SyncedFunctionUrlConfig metadata) + var content = templateFormat == CloudFormationTemplateFormat.Json + ? @"{ + 'AWSTemplateFormatVersion': '2010-09-09', + 'Transform': 'AWS::Serverless-2016-10-31', + 'Resources': { + 'TestMethod': { + 'Type': 'AWS::Serverless::Function', + 'Metadata': { + 'Tool': 'Amazon.Lambda.Annotations' + }, + 'Properties': { + 'Runtime': 'dotnet8', + 'CodeUri': '', + 'MemorySize': 512, + 'Timeout': 30, + 'Policies': ['AWSLambdaBasicExecutionRole'], + 'PackageType': 'Image', + 'ImageUri': '.', + 'ImageConfig': { 'Command': ['MyAssembly::MyNamespace.MyType::Handler'] }, + 'FunctionUrlConfig': { + 'AuthType': 'AWS_IAM' + } + } + } + } + }" + : "AWSTemplateFormatVersion: '2010-09-09'\nTransform: AWS::Serverless-2016-10-31\nResources:\n TestMethod:\n Type: AWS::Serverless::Function\n Metadata:\n Tool: Amazon.Lambda.Annotations\n Properties:\n Runtime: dotnet8\n CodeUri: ''\n MemorySize: 512\n Timeout: 30\n Policies:\n - AWSLambdaBasicExecutionRole\n PackageType: Image\n ImageUri: .\n ImageConfig:\n Command:\n - 'MyAssembly::MyNamespace.MyType::Handler'\n FunctionUrlConfig:\n AuthType: AWS_IAM"; + + var mockFileManager = GetMockFileManager(content); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + // No FunctionUrl attribute + lambdaFunctionModel.Attributes = new List(); + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + // The manually-added FunctionUrlConfig should be preserved + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + Assert.Equal("AWS_IAM", templateWriter.GetToken("Resources.TestMethod.Properties.FunctionUrlConfig.AuthType")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlMetadataTrackedAndCleanedUp(CloudFormationTemplateFormat templateFormat) + { + // First pass: create FunctionUrlConfig via attribute + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel { Data = new FunctionUrlAttribute() } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + + // Verify metadata is set + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + Assert.True(templateWriter.GetToken("Resources.TestMethod.Metadata.SyncedFunctionUrlConfig")); + + // Second pass: remove the attribute + lambdaFunctionModel.Attributes = new List(); + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + + // Verify both FunctionUrlConfig and metadata are cleaned up + Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + Assert.False(templateWriter.Exists("Resources.TestMethod.Metadata.SyncedFunctionUrlConfig")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void SwitchFromFunctionUrlToHttpApi(CloudFormationTemplateFormat templateFormat) + { + // First pass: FunctionUrl + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel { Data = new FunctionUrlAttribute() } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + + // Second pass: switch to HttpApi + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new HttpApiAttribute(LambdaHttpMethod.Get, "/items") + } + }; + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.Events.RootGet")); + Assert.Equal("HttpApi", templateWriter.GetToken("Resources.TestMethod.Properties.Events.RootGet.Type")); + } + } +} diff --git a/Libraries/test/IntegrationTests.Helpers/LambdaHelper.cs b/Libraries/test/IntegrationTests.Helpers/LambdaHelper.cs index 6436c7c7b..591eb5e06 100644 --- a/Libraries/test/IntegrationTests.Helpers/LambdaHelper.cs +++ b/Libraries/test/IntegrationTests.Helpers/LambdaHelper.cs @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using System.Collections.Generic; using System.Threading.Tasks; using Amazon.Lambda; @@ -55,6 +58,14 @@ public async Task ListEventSourceMappingsAsync( }); } + public async Task GetFunctionUrlConfigAsync(string functionName) + { + return await _lambdaClient.GetFunctionUrlConfigAsync(new GetFunctionUrlConfigRequest + { + FunctionName = functionName + }); + } + public async Task WaitTillNotPending(List functions) { foreach (var function in functions) diff --git a/Libraries/test/TestServerlessApp.IntegrationTests/FunctionUrlExample.cs b/Libraries/test/TestServerlessApp.IntegrationTests/FunctionUrlExample.cs new file mode 100644 index 000000000..b3f97929b --- /dev/null +++ b/Libraries/test/TestServerlessApp.IntegrationTests/FunctionUrlExample.cs @@ -0,0 +1,103 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace TestServerlessApp.IntegrationTests +{ + [Collection("Integration Tests")] + public class FunctionUrlExample + { + private readonly IntegrationTestContextFixture _fixture; + + public FunctionUrlExample(IntegrationTestContextFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task GetItems_WithCategory_ReturnsOkWithItems() + { + Assert.False(string.IsNullOrEmpty(_fixture.FunctionUrlPrefix), "FunctionUrlPrefix should not be empty. The Function URL was not discovered during setup."); + + var response = await GetWithRetryAsync($"{_fixture.FunctionUrlPrefix}?category=electronics"); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + var json = JObject.Parse(content); + + Assert.Equal("electronics", json["category"]?.ToString()); + Assert.NotNull(json["items"]); + var items = json["items"].ToObject(); + Assert.Equal(2, items.Length); + Assert.Contains("item1", items); + Assert.Contains("item2", items); + } + + [Fact] + public async Task GetItems_LogsToCloudWatch() + { + Assert.False(string.IsNullOrEmpty(_fixture.FunctionUrlPrefix), "FunctionUrlPrefix should not be empty. The Function URL was not discovered during setup."); + + var response = await GetWithRetryAsync($"{_fixture.FunctionUrlPrefix}?category=books"); + response.EnsureSuccessStatusCode(); + + var lambdaFunctionName = _fixture.LambdaFunctions + .FirstOrDefault(x => string.Equals(x.LogicalId, "TestServerlessAppFunctionUrlExampleGetItemsGenerated"))?.Name; + Assert.False(string.IsNullOrEmpty(lambdaFunctionName)); + + var logGroupName = _fixture.CloudWatchHelper.GetLogGroupName(lambdaFunctionName); + Assert.True( + await _fixture.CloudWatchHelper.MessageExistsInRecentLogEventsAsync("Getting items for category: books", logGroupName, logGroupName), + "Expected log message not found in CloudWatch logs"); + } + + [Fact] + public async Task VerifyFunctionUrlConfig_HasNoneAuthType() + { + var lambdaFunctionName = _fixture.LambdaFunctions + .FirstOrDefault(x => string.Equals(x.LogicalId, "TestServerlessAppFunctionUrlExampleGetItemsGenerated"))?.Name; + Assert.False(string.IsNullOrEmpty(lambdaFunctionName)); + + var functionUrlConfig = await _fixture.LambdaHelper.GetFunctionUrlConfigAsync(lambdaFunctionName); + Assert.NotNull(functionUrlConfig); + Assert.Equal("NONE", functionUrlConfig.AuthType.Value); + Assert.False(string.IsNullOrEmpty(functionUrlConfig.FunctionUrl), "Function URL should not be empty"); + Assert.Contains(".lambda-url.", functionUrlConfig.FunctionUrl); + } + + private async Task GetWithRetryAsync(string url) + { + const int maxAttempts = 10; + HttpResponseMessage response = null; + + for (var attempt = 0; attempt < maxAttempts; attempt++) + { + await Task.Delay(attempt * 1000); + try + { + response = await _fixture.HttpClient.GetAsync(url); + + // If we get a 403 Forbidden, it may be an eventual consistency issue + // with the Function URL permissions propagating. + if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) + continue; + + break; + } + catch + { + if (attempt + 1 == maxAttempts) + throw; + } + } + + return response; + } + } +} diff --git a/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs b/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs index 0102424a8..a869d6cb1 100644 --- a/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs +++ b/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using System; using System.Collections.Generic; using System.IO; @@ -30,6 +33,7 @@ public class IntegrationTestContextFixture : IAsyncLifetime public string RestApiUrlPrefix; public string HttpApiUrlPrefix; + public string FunctionUrlPrefix; public string TestQueueARN; public string TestS3BucketName; public List LambdaFunctions; @@ -96,6 +100,16 @@ public async Task InitializeAsync() await LambdaHelper.WaitTillNotPending(LambdaFunctions.Where(x => x.Name != null).Select(x => x.Name).ToList()); + // Discover the Function URL for the FunctionUrlExample function + var functionUrlLambdaName = LambdaFunctions + .FirstOrDefault(x => string.Equals(x.LogicalId, "TestServerlessAppFunctionUrlExampleGetItemsGenerated"))?.Name; + if (!string.IsNullOrEmpty(functionUrlLambdaName)) + { + var functionUrlConfig = await LambdaHelper.GetFunctionUrlConfigAsync(functionUrlLambdaName); + FunctionUrlPrefix = functionUrlConfig.FunctionUrl.TrimEnd('/'); + Console.WriteLine($"[IntegrationTest] FunctionUrlPrefix: {FunctionUrlPrefix}"); + } + // Wait an additional 10 seconds for any other eventually consistency state to finish up. await Task.Delay(10000); } diff --git a/Libraries/test/TestServerlessApp/FunctionUrlExample.cs b/Libraries/test/TestServerlessApp/FunctionUrlExample.cs new file mode 100644 index 000000000..4909c768e --- /dev/null +++ b/Libraries/test/TestServerlessApp/FunctionUrlExample.cs @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.Core; + +namespace TestServerlessApp +{ + public class FunctionUrlExample + { + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [FunctionUrl(AuthType = FunctionUrlAuthType.NONE)] + public IHttpResult GetItems([FromQuery] string category, ILambdaContext context) + { + context.Logger.LogLine($"Getting items for category: {category}"); + return HttpResults.Ok(new { items = new[] { "item1", "item2" }, category }); + } + } +} diff --git a/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json b/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json index 0b96350ff..71f6d708b 100644 --- a/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json +++ b/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json @@ -13,7 +13,7 @@ "template": "serverless.template", "template-parameters": "", "docker-host-build-output-dir": "./bin/Release/lambda-publish", - "s3-bucket": "test-serverless-app", - "stack-name": "test-serverless-app", - "function-architecture": "x86_64" -} \ No newline at end of file +"s3-bucket" : "test-serverless-app-535afbc5", +"stack-name" : "test-serverless-app-535afbc5", +"function-architecture" : "x86_64" +} diff --git a/Libraries/test/TestServerlessApp/serverless.template b/Libraries/test/TestServerlessApp/serverless.template index d5b4b3cb0..235b21f43 100644 --- a/Libraries/test/TestServerlessApp/serverless.template +++ b/Libraries/test/TestServerlessApp/serverless.template @@ -3,9 +3,6 @@ "Transform": "AWS::Serverless-2016-10-31", "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.11.0.0).", "Resources": { - "TestQueue": { - "Type": "AWS::SQS::Queue" - }, "AnnotationsHttpApi": { "Type": "AWS::Serverless::HttpApi", "Metadata": { @@ -801,6 +798,30 @@ } } }, + "TestServerlessAppFunctionUrlExampleGetItemsGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedFunctionUrlConfig": true + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.FunctionUrlExample_GetItems_Generated::GetItems" + ] + }, + "FunctionUrlConfig": { + "AuthType": "NONE" + } + } + }, "GreeterSayHello": { "Type": "AWS::Serverless::Function", "Metadata": { @@ -991,6 +1012,60 @@ } } }, + "S3EventHandler": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "TestS3Bucket" + ], + "SyncedEventProperties": { + "TestS3Bucket": [ + "Bucket.Ref", + "Events", + "Filter.S3Key.Rules" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole", + "AmazonS3ReadOnlyAccess" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.S3EventExamples.S3EventProcessing_ProcessS3Event_Generated::ProcessS3Event" + ] + }, + "Events": { + "TestS3Bucket": { + "Type": "S3", + "Properties": { + "Bucket": { + "Ref": "TestS3Bucket" + }, + "Events": [ + "s3:ObjectCreated:*" + ], + "Filter": { + "S3Key": { + "Rules": [ + { + "Name": "suffix", + "Value": ".json" + } + ] + } + } + } + } + } + } + }, "SimpleCalculatorAdd": { "Type": "AWS::Serverless::Function", "Metadata": { @@ -1254,6 +1329,12 @@ "TestQueueEvent": { "Type": "SQS", "Properties": { + "Queue": { + "Fn::GetAtt": [ + "TestQueue", + "Arn" + ] + }, "BatchSize": 50, "FilterCriteria": { "Filters": [ @@ -1268,12 +1349,6 @@ "MaximumBatchingWindowInSeconds": 5, "ScalingConfig": { "MaximumConcurrency": 5 - }, - "Queue": { - "Fn::GetAtt": [ - "TestQueue", - "Arn" - ] } } } @@ -1339,63 +1414,6 @@ ] } } - }, - "S3EventHandler": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "TestS3Bucket" - ], - "SyncedEventProperties": { - "TestS3Bucket": [ - "Bucket.Ref", - "Events", - "Filter.S3Key.Rules" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole", - "AmazonS3ReadOnlyAccess" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.S3EventExamples.S3EventProcessing_ProcessS3Event_Generated::ProcessS3Event" - ] - }, - "Events": { - "TestS3Bucket": { - "Type": "S3", - "Properties": { - "Events": [ - "s3:ObjectCreated:*" - ], - "Filter": { - "S3Key": { - "Rules": [ - { - "Name": "suffix", - "Value": ".json" - } - ] - } - }, - "Bucket": { - "Ref": "TestS3Bucket" - } - } - } - } - } - }, - "TestS3Bucket": { - "Type": "AWS::S3::Bucket" } } } \ No newline at end of file