Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .autover/changes/function-url-annotations-support.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext
Type = TypeModelBuilder.Build(att.AttributeClass, context)
};
}
else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.FunctionUrlAttribute), SymbolEqualityComparer.Default))
{
var data = FunctionUrlAttributeBuilder.Build(att);
model = new AttributeModel<FunctionUrlAttribute>
{
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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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 => v.Value as string).ToArray();

var allowHeaders = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AllowHeaders").Value;
if (!allowHeaders.IsNull)
data.AllowHeaders = allowHeaders.Values.Select(v => v.Value as string).ToArray();

var exposeHeaders = att.NamedArguments.FirstOrDefault(arg => arg.Key == "ExposeHeaders").Value;
if (!exposeHeaders.IsNull)
data.ExposeHeaders = exposeHeaders.Values.Select(v => v.Value as string).ToArray();

var allowCredentials = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AllowCredentials").Value.Value;
if (allowCredentials != null)
data.AllowCredentials = (bool)allowCredentials;

var maxAge = att.NamedArguments.FirstOrDefault(arg => arg.Key == "MaxAge").Value.Value;
if (maxAge != null)
data.MaxAge = (int)maxAge;

return data;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ public static HashSet<EventType> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ private static TypeModel BuildResponseType(IMethodSymbol lambdaMethodSymbol,
throw new ArgumentOutOfRangeException();
}
}
else if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.FunctionUrlAttribute))
{
// Function URLs use the same payload format as HTTP API v2
var symbol = lambdaMethodModel.ReturnsVoidOrGenericTask ?
task.Construct(context.Compilation.GetTypeByMetadataName(TypeFullNames.APIGatewayHttpApiV2ProxyResponse)):
context.Compilation.GetTypeByMetadataName(TypeFullNames.APIGatewayHttpApiV2ProxyResponse);
return TypeModelBuilder.Build(symbol, context);
}
else
{
return lambdaMethodModel.ReturnType;
Expand Down Expand Up @@ -277,6 +285,20 @@ private static IList<ParameterModel> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ internal class SyntaxReceiver : ISyntaxContextReceiver
{ "RestApiAuthorizerAttribute", "RestApiAuthorizer" },
{ "HttpApiAttribute", "HttpApi" },
{ "RestApiAttribute", "RestApi" },
{ "FunctionUrlAttribute", "FunctionUrl" },
{ "SQSEventAttribute", "SQSEvent" }
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ public static class TypeFullNames
public const string FromRouteAttribute = "Amazon.Lambda.Annotations.APIGateway.FromRouteAttribute";
public const string FromCustomAuthorizerAttribute = "Amazon.Lambda.Annotations.APIGateway.FromCustomAuthorizerAttribute";

public const string FunctionUrlAttribute = "Amazon.Lambda.Annotations.APIGateway.FunctionUrlAttribute";
public const string FunctionUrlAuthType = "Amazon.Lambda.Annotations.APIGateway.FunctionUrlAuthType";

public const string HttpApiAuthorizerAttribute = "Amazon.Lambda.Annotations.APIGateway.HttpApiAuthorizerAttribute";
public const string RestApiAuthorizerAttribute = "Amazon.Lambda.Annotations.APIGateway.RestApiAuthorizerAttribute";

Expand Down Expand Up @@ -67,6 +70,7 @@ public static class TypeFullNames
{
RestApiAttribute,
HttpApiAttribute,
FunctionUrlAttribute,
SQSEventAttribute
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ internal static bool ValidateDependencies(GeneratorExecutionContext context, IMe
{
// Check for references to "Amazon.Lambda.APIGatewayEvents" if the Lambda method is annotated with RestApi, HttpApi, or authorizer attributes.
if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.RestApiAttribute) || lambdaMethodSymbol.HasAttribute(context, TypeFullNames.HttpApiAttribute)
|| lambdaMethodSymbol.HasAttribute(context, TypeFullNames.FunctionUrlAttribute)
|| lambdaMethodSymbol.HasAttribute(context, TypeFullNames.HttpApiAuthorizerAttribute) || lambdaMethodSymbol.HasAttribute(context, TypeFullNames.RestApiAuthorizerAttribute))
{
if (context.Compilation.ReferencedAssemblyNames.FirstOrDefault(x => x.Name == "Amazon.Lambda.APIGatewayEvents") == null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la
{
var currentSyncedEvents = new List<string>();
var currentSyncedEventProperties = new Dictionary<string, List<string>>();
var hasFunctionUrl = false;

foreach (var attributeModel in lambdaFunction.Attributes)
{
Expand All @@ -221,9 +222,19 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la
eventName = ProcessSqsAttribute(lambdaFunction, sqsAttributeModel.Data, currentSyncedEventProperties);
currentSyncedEvents.Add(eventName);
break;
case AttributeModel<FunctionUrlAttribute> functionUrlAttributeModel:
ProcessFunctionUrlAttribute(lambdaFunction, functionUrlAttributeModel.Data);
hasFunctionUrl = true;
break;
}
}

// Remove FunctionUrlConfig if the attribute was removed
if (!hasFunctionUrl)
{
_templateWriter.RemoveToken($"Resources.{lambdaFunction.ResourceName}.Properties.FunctionUrlConfig");
}

SynchronizeEventsAndProperties(currentSyncedEvents, currentSyncedEventProperties, lambdaFunction);
}

Expand Down Expand Up @@ -290,6 +301,50 @@ private string ProcessHttpApiAttribute(ILambdaFunctionSerializable lambdaFunctio
return eventName;
}

/// <summary>
/// Writes the <see cref="FunctionUrlAttribute"/> 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.
/// </summary>
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<string>(functionUrlAttribute.AllowOrigins), TokenType.List);

if (functionUrlAttribute.AllowMethods != null)
_templateWriter.SetToken($"{corsPath}.AllowMethods", new List<string>(functionUrlAttribute.AllowMethods), TokenType.List);

if (functionUrlAttribute.AllowHeaders != null)
_templateWriter.SetToken($"{corsPath}.AllowHeaders", new List<string>(functionUrlAttribute.AllowHeaders), TokenType.List);

if (functionUrlAttribute.ExposeHeaders != null)
_templateWriter.SetToken($"{corsPath}.ExposeHeaders", new List<string>(functionUrlAttribute.ExposeHeaders), TokenType.List);

if (functionUrlAttribute.AllowCredentials)
_templateWriter.SetToken($"{corsPath}.AllowCredentials", true);

if (functionUrlAttribute.MaxAge > 0)
_templateWriter.SetToken($"{corsPath}.MaxAge", functionUrlAttribute.MaxAge);
}
}

/// <summary>
/// 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System;

namespace Amazon.Lambda.Annotations.APIGateway
{
/// <summary>
/// Configures the Lambda function to be invoked via a Lambda Function URL.
/// </summary>
/// <remarks>
/// Function URLs use the same payload format as HTTP API v2 (APIGatewayHttpApiV2ProxyRequest/Response).
/// </remarks>
[AttributeUsage(AttributeTargets.Method)]
public class FunctionUrlAttribute : Attribute
{
/// <inheritdoc cref="FunctionUrlAuthType"/>
public FunctionUrlAuthType AuthType { get; set; } = FunctionUrlAuthType.NONE;

/// <summary>
/// The allowed origins for CORS requests. Example: new[] { "https://example.com" }
/// </summary>
public string[] AllowOrigins { get; set; }

/// <summary>
/// The allowed HTTP methods for CORS requests. Example: new[] { "GET", "POST" }
/// </summary>
public string[] AllowMethods { get; set; }

/// <summary>
/// The allowed headers for CORS requests.
/// </summary>
public string[] AllowHeaders { get; set; }

/// <summary>
/// Whether credentials are included in the CORS request.
/// </summary>
public bool AllowCredentials { get; set; }

/// <summary>
/// The expose headers for CORS responses.
/// </summary>
public string[] ExposeHeaders { get; set; }

/// <summary>
/// The maximum time in seconds that a browser can cache the CORS preflight response.
/// A value of 0 means the property is not set.
/// </summary>
public int MaxAge { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Amazon.Lambda.Annotations.APIGateway
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

technically function url is completely different than api gateway but uses the same api gateway v2 response type, so i kept it in the same namespace for now

{
/// <summary>
/// The type of authentication for a Lambda Function URL.
/// </summary>
public enum FunctionUrlAuthType
{
/// <summary>
/// No authentication. Anyone with the Function URL can invoke the function.
/// </summary>
NONE,

/// <summary>
/// IAM authentication. Only authenticated IAM users and roles can invoke the function.
/// </summary>
AWS_IAM
Comment on lines +11 to +16
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FunctionUrlAuthType introduces enum members in ALL_CAPS (NONE, AWS_IAM), which is inconsistent with the rest of the public enums in this package (e.g., LambdaHttpMethod.Get, HttpApiVersion.V2). Consider using PascalCase member names (e.g., None, AwsIam) and mapping to the SAM-required strings in the template writer (so public API stays idiomatic while still emitting NONE/AWS_IAM).

Suggested change
NONE,
/// <summary>
/// IAM authentication. Only authenticated IAM users and roles can invoke the function.
/// </summary>
AWS_IAM
None,
/// <summary>
/// IAM authentication. Only authenticated IAM users and roles can invoke the function.
/// </summary>
AwsIam

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think it should be all caps

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// <auto-generated/>

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;

/// <summary>
/// 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.
/// </summary>
public FunctionUrlExample_GetItems_Generated()
{
SetExecutionEnvironment();
functionUrlExample = new FunctionUrlExample();
serializer = new Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer();
}

/// <summary>
/// The generated Lambda function handler for <see cref="GetItems(string, Amazon.Lambda.Core.ILambdaContext)"/>
/// </summary>
/// <param name="__request__">The Function URL request object that will be processed by the Lambda function handler.</param>
/// <param name="__context__">The ILambdaContext that provides methods for logging and describing the Lambda environment.</param>
/// <returns>Result of the Lambda function execution</returns>
public System.IO.Stream GetItems(Amazon.Lambda.APIGatewayEvents.APIGatewayHttpApiV2ProxyRequest __request__, Amazon.Lambda.Core.ILambdaContext __context__)
{
var validationErrors = new List<string>();

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<string, string>
{
{"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());
}
}
}
Loading
Loading