Skip to content

Commit 41ed661

Browse files
committed
Phase 1: Add FunctionUrlAttribute with source generator wiring and CloudFormation FunctionUrlConfig generation
- New FunctionUrlAttribute class with AuthType property (NONE/AWS_IAM) - New FunctionUrlAuthType enum - Source generator detects FunctionUrlAttribute and maps to EventType.API - Generated wrapper uses HttpApi V2 request/response types (same payload format) - CloudFormationWriter emits FunctionUrlConfig on the function resource - Dependency validation checks for Amazon.Lambda.APIGatewayEvents - SyntaxReceiver detects missing [LambdaFunction] on [FunctionUrl] methods - 6 new unit tests for CloudFormation template generation (JSON + YAML) Phase 2: Add CORS support to FunctionUrlAttribute - AllowOrigins, AllowMethods, AllowHeaders, ExposeHeaders, AllowCredentials, MaxAge properties - FunctionUrlAttributeBuilder parses all CORS properties from AttributeData - CloudFormationWriter emits Cors block under FunctionUrlConfig only when CORS properties are set - 4 new unit tests for CORS generation and no-CORS scenarios Phase 3: FunctionUrlConfig orphan cleanup and attribute switching - Remove FunctionUrlConfig from template when [FunctionUrl] attribute is removed - Clean transition when switching from [FunctionUrl] to [HttpApi] or [RestApi] - 4 new unit tests for orphan cleanup and attribute switching scenarios Phase 4: End-to-end source generator test for FunctionUrl - FunctionUrlExample.cs test source with [FunctionUrl] + [FromQuery] + IHttpResult - Generated wrapper snapshot using HttpApi V2 payload format - Serverless template snapshot with FunctionUrlConfig - Full Roslyn source generator verification test IT tests Update Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> copilot comments change file fix cleanup
1 parent d86f1af commit 41ed661

File tree

21 files changed

+963
-9
lines changed

21 files changed

+963
-9
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"Projects": [
3+
{
4+
"Name": "Amazon.Lambda.Annotations",
5+
"Type": "Minor",
6+
"ChangelogMessages": [
7+
"Added [FunctionUrl] attribute for configuring Lambda functions with Function URL endpoints, including optional CORS support"
8+
]
9+
}
10+
]
11+
}

Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,15 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext
9191
Type = TypeModelBuilder.Build(att.AttributeClass, context)
9292
};
9393
}
94+
else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.FunctionUrlAttribute), SymbolEqualityComparer.Default))
95+
{
96+
var data = FunctionUrlAttributeBuilder.Build(att);
97+
model = new AttributeModel<FunctionUrlAttribute>
98+
{
99+
Data = data,
100+
Type = TypeModelBuilder.Build(att.AttributeClass, context)
101+
};
102+
}
94103
else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.HttpApiAuthorizerAttribute), SymbolEqualityComparer.Default))
95104
{
96105
var data = HttpApiAuthorizerAttributeBuilder.Build(att);
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using System.Linq;
2+
using Amazon.Lambda.Annotations.APIGateway;
3+
using Microsoft.CodeAnalysis;
4+
5+
namespace Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes
6+
{
7+
public static class FunctionUrlAttributeBuilder
8+
{
9+
public static FunctionUrlAttribute Build(AttributeData att)
10+
{
11+
var authType = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AuthType").Value.Value;
12+
13+
var data = new FunctionUrlAttribute
14+
{
15+
AuthType = authType == null ? FunctionUrlAuthType.NONE : (FunctionUrlAuthType)authType
16+
};
17+
18+
var allowOrigins = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AllowOrigins").Value;
19+
if (!allowOrigins.IsNull)
20+
data.AllowOrigins = allowOrigins.Values.Select(v => v.Value as string).ToArray();
21+
22+
var allowMethods = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AllowMethods").Value;
23+
if (!allowMethods.IsNull)
24+
data.AllowMethods = allowMethods.Values.Select(v => v.Value as string).ToArray();
25+
26+
var allowHeaders = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AllowHeaders").Value;
27+
if (!allowHeaders.IsNull)
28+
data.AllowHeaders = allowHeaders.Values.Select(v => v.Value as string).ToArray();
29+
30+
var exposeHeaders = att.NamedArguments.FirstOrDefault(arg => arg.Key == "ExposeHeaders").Value;
31+
if (!exposeHeaders.IsNull)
32+
data.ExposeHeaders = exposeHeaders.Values.Select(v => v.Value as string).ToArray();
33+
34+
var allowCredentials = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AllowCredentials").Value.Value;
35+
if (allowCredentials != null)
36+
data.AllowCredentials = (bool)allowCredentials;
37+
38+
var maxAge = att.NamedArguments.FirstOrDefault(arg => arg.Key == "MaxAge").Value.Value;
39+
if (maxAge != null)
40+
data.MaxAge = (int)maxAge;
41+
42+
return data;
43+
}
44+
}
45+
}

Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ public static HashSet<EventType> Build(IMethodSymbol lambdaMethodSymbol,
1818
foreach (var attribute in lambdaMethodSymbol.GetAttributes())
1919
{
2020
if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.RestApiAttribute
21-
|| attribute.AttributeClass.ToDisplayString() == TypeFullNames.HttpApiAttribute)
21+
|| attribute.AttributeClass.ToDisplayString() == TypeFullNames.HttpApiAttribute
22+
|| attribute.AttributeClass.ToDisplayString() == TypeFullNames.FunctionUrlAttribute)
2223
{
2324
events.Add(EventType.API);
2425
}

Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,14 @@ private static TypeModel BuildResponseType(IMethodSymbol lambdaMethodSymbol,
144144
context.Compilation.GetTypeByMetadataName(TypeFullNames.ApplicationLoadBalancerResponse);
145145
return TypeModelBuilder.Build(symbol, context);
146146
}
147+
else if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.FunctionUrlAttribute))
148+
{
149+
// Function URLs use the same payload format as HTTP API v2
150+
var symbol = lambdaMethodModel.ReturnsVoidOrGenericTask ?
151+
task.Construct(context.Compilation.GetTypeByMetadataName(TypeFullNames.APIGatewayHttpApiV2ProxyResponse)):
152+
context.Compilation.GetTypeByMetadataName(TypeFullNames.APIGatewayHttpApiV2ProxyResponse);
153+
return TypeModelBuilder.Build(symbol, context);
154+
}
147155
else
148156
{
149157
return lambdaMethodModel.ReturnType;
@@ -304,6 +312,20 @@ private static IList<ParameterModel> BuildParameters(IMethodSymbol lambdaMethodS
304312
parameters.Add(requestParameter);
305313
parameters.Add(contextParameter);
306314
}
315+
else if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.FunctionUrlAttribute))
316+
{
317+
// Function URLs use the same payload format as HTTP API v2
318+
var symbol = context.Compilation.GetTypeByMetadataName(TypeFullNames.APIGatewayHttpApiV2ProxyRequest);
319+
var type = TypeModelBuilder.Build(symbol, context);
320+
var requestParameter = new ParameterModel
321+
{
322+
Name = "__request__",
323+
Type = type,
324+
Documentation = "The Function URL request object that will be processed by the Lambda function handler."
325+
};
326+
parameters.Add(requestParameter);
327+
parameters.Add(contextParameter);
328+
}
307329
else
308330
{
309331
// Lambda method with no event attribute are plain lambda functions, therefore, generated method will have

Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ internal class SyntaxReceiver : ISyntaxContextReceiver
2121
{ "RestApiAuthorizerAttribute", "RestApiAuthorizer" },
2222
{ "HttpApiAttribute", "HttpApi" },
2323
{ "RestApiAttribute", "RestApi" },
24+
{ "FunctionUrlAttribute", "FunctionUrl" },
2425
{ "SQSEventAttribute", "SQSEvent" },
2526
{ "ALBApiAttribute", "ALBApi" }
2627
};
@@ -121,4 +122,4 @@ public void OnVisitSyntaxNode(GeneratorSyntaxContext context)
121122
}
122123
}
123124
}
124-
}
125+
}

Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ public static class TypeFullNames
3434
public const string FromRouteAttribute = "Amazon.Lambda.Annotations.APIGateway.FromRouteAttribute";
3535
public const string FromCustomAuthorizerAttribute = "Amazon.Lambda.Annotations.APIGateway.FromCustomAuthorizerAttribute";
3636

37+
public const string FunctionUrlAttribute = "Amazon.Lambda.Annotations.APIGateway.FunctionUrlAttribute";
38+
public const string FunctionUrlAuthType = "Amazon.Lambda.Annotations.APIGateway.FunctionUrlAuthType";
39+
3740
public const string HttpApiAuthorizerAttribute = "Amazon.Lambda.Annotations.APIGateway.HttpApiAuthorizerAttribute";
3841
public const string RestApiAuthorizerAttribute = "Amazon.Lambda.Annotations.APIGateway.RestApiAuthorizerAttribute";
3942

@@ -79,8 +82,9 @@ public static class TypeFullNames
7982
{
8083
RestApiAttribute,
8184
HttpApiAttribute,
85+
FunctionUrlAttribute,
8286
SQSEventAttribute,
8387
ALBApiAttribute
8488
};
8589
}
86-
}
90+
}

Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ internal static bool ValidateDependencies(GeneratorExecutionContext context, IMe
6969
{
7070
// Check for references to "Amazon.Lambda.APIGatewayEvents" if the Lambda method is annotated with RestApi, HttpApi, or authorizer attributes.
7171
if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.RestApiAttribute) || lambdaMethodSymbol.HasAttribute(context, TypeFullNames.HttpApiAttribute)
72+
|| lambdaMethodSymbol.HasAttribute(context, TypeFullNames.FunctionUrlAttribute)
7273
|| lambdaMethodSymbol.HasAttribute(context, TypeFullNames.HttpApiAuthorizerAttribute) || lambdaMethodSymbol.HasAttribute(context, TypeFullNames.RestApiAuthorizerAttribute))
7374
{
7475
if (context.Compilation.ReferencedAssemblyNames.FirstOrDefault(x => x.Name == "Amazon.Lambda.APIGatewayEvents") == null)

Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la
205205
var currentSyncedEvents = new List<string>();
206206
var currentSyncedEventProperties = new Dictionary<string, List<string>>();
207207
var currentAlbResources = new List<string>();
208+
var hasFunctionUrl = false;
208209

209210
foreach (var attributeModel in lambdaFunction.Attributes)
210211
{
@@ -227,6 +228,23 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la
227228
var albResourceNames = ProcessAlbApiAttribute(lambdaFunction, albAttributeModel.Data);
228229
currentAlbResources.AddRange(albResourceNames);
229230
break;
231+
case AttributeModel<FunctionUrlAttribute> functionUrlAttributeModel:
232+
ProcessFunctionUrlAttribute(lambdaFunction, functionUrlAttributeModel.Data);
233+
_templateWriter.SetToken($"Resources.{lambdaFunction.ResourceName}.Metadata.SyncedFunctionUrlConfig", true);
234+
hasFunctionUrl = true;
235+
break;
236+
}
237+
}
238+
239+
// Remove FunctionUrlConfig only if it was previously created by Annotations (tracked via metadata).
240+
// This preserves any manually-added FunctionUrlConfig that was not created by the source generator.
241+
if (!hasFunctionUrl)
242+
{
243+
var syncedFunctionUrlConfigPath = $"Resources.{lambdaFunction.ResourceName}.Metadata.SyncedFunctionUrlConfig";
244+
if (_templateWriter.GetToken<bool>(syncedFunctionUrlConfigPath, false))
245+
{
246+
_templateWriter.RemoveToken($"Resources.{lambdaFunction.ResourceName}.Properties.FunctionUrlConfig");
247+
_templateWriter.RemoveToken(syncedFunctionUrlConfigPath);
230248
}
231249
}
232250

@@ -297,6 +315,50 @@ private string ProcessHttpApiAttribute(ILambdaFunctionSerializable lambdaFunctio
297315
return eventName;
298316
}
299317

318+
/// <summary>
319+
/// Writes the <see cref="FunctionUrlAttribute"/> configuration to the serverless template.
320+
/// Unlike HttpApi/RestApi, Function URLs are configured as a property on the function resource
321+
/// rather than as an event source.
322+
/// </summary>
323+
private void ProcessFunctionUrlAttribute(ILambdaFunctionSerializable lambdaFunction, FunctionUrlAttribute functionUrlAttribute)
324+
{
325+
var functionUrlConfigPath = $"Resources.{lambdaFunction.ResourceName}.Properties.FunctionUrlConfig";
326+
_templateWriter.SetToken($"{functionUrlConfigPath}.AuthType", functionUrlAttribute.AuthType.ToString());
327+
328+
// Always remove the existing Cors block first to clear any stale properties
329+
// from a previous generation pass, then re-emit only the currently configured values.
330+
var corsPath = $"{functionUrlConfigPath}.Cors";
331+
_templateWriter.RemoveToken(corsPath);
332+
333+
var hasCors = functionUrlAttribute.AllowOrigins != null
334+
|| functionUrlAttribute.AllowMethods != null
335+
|| functionUrlAttribute.AllowHeaders != null
336+
|| functionUrlAttribute.ExposeHeaders != null
337+
|| functionUrlAttribute.AllowCredentials
338+
|| functionUrlAttribute.MaxAge > 0;
339+
340+
if (hasCors)
341+
{
342+
if (functionUrlAttribute.AllowOrigins != null)
343+
_templateWriter.SetToken($"{corsPath}.AllowOrigins", new List<string>(functionUrlAttribute.AllowOrigins), TokenType.List);
344+
345+
if (functionUrlAttribute.AllowMethods != null)
346+
_templateWriter.SetToken($"{corsPath}.AllowMethods", new List<string>(functionUrlAttribute.AllowMethods), TokenType.List);
347+
348+
if (functionUrlAttribute.AllowHeaders != null)
349+
_templateWriter.SetToken($"{corsPath}.AllowHeaders", new List<string>(functionUrlAttribute.AllowHeaders), TokenType.List);
350+
351+
if (functionUrlAttribute.ExposeHeaders != null)
352+
_templateWriter.SetToken($"{corsPath}.ExposeHeaders", new List<string>(functionUrlAttribute.ExposeHeaders), TokenType.List);
353+
354+
if (functionUrlAttribute.AllowCredentials)
355+
_templateWriter.SetToken($"{corsPath}.AllowCredentials", true);
356+
357+
if (functionUrlAttribute.MaxAge > 0)
358+
_templateWriter.SetToken($"{corsPath}.MaxAge", functionUrlAttribute.MaxAge);
359+
}
360+
}
361+
300362
/// <summary>
301363
/// Processes all authorizers and writes them to the serverless template as inline authorizers within the API resources.
302364
/// AWS SAM expects authorizers to be defined within the Auth.Authorizers property of AWS::Serverless::HttpApi or AWS::Serverless::Api resources.
@@ -1129,4 +1191,4 @@ private void SynchronizeEventsAndProperties(List<string> syncedEvents, Dictionar
11291191
_templateWriter.SetToken(syncedEventPropertiesPath, syncedEventProperties, TokenType.KeyVal);
11301192
}
11311193
}
1132-
}
1194+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using System;
2+
3+
namespace Amazon.Lambda.Annotations.APIGateway
4+
{
5+
/// <summary>
6+
/// Configures the Lambda function to be invoked via a Lambda Function URL.
7+
/// </summary>
8+
/// <remarks>
9+
/// Function URLs use the same payload format as HTTP API v2 (APIGatewayHttpApiV2ProxyRequest/Response).
10+
/// </remarks>
11+
[AttributeUsage(AttributeTargets.Method)]
12+
public class FunctionUrlAttribute : Attribute
13+
{
14+
/// <inheritdoc cref="FunctionUrlAuthType"/>
15+
public FunctionUrlAuthType AuthType { get; set; } = FunctionUrlAuthType.NONE;
16+
17+
/// <summary>
18+
/// The allowed origins for CORS requests. Example: new[] { "https://example.com" }
19+
/// </summary>
20+
public string[] AllowOrigins { get; set; }
21+
22+
/// <summary>
23+
/// The allowed HTTP methods for CORS requests. Example: new[] { "GET", "POST" }
24+
/// </summary>
25+
public string[] AllowMethods { get; set; }
26+
27+
/// <summary>
28+
/// The allowed headers for CORS requests.
29+
/// </summary>
30+
public string[] AllowHeaders { get; set; }
31+
32+
/// <summary>
33+
/// Whether credentials are included in the CORS request.
34+
/// </summary>
35+
public bool AllowCredentials { get; set; }
36+
37+
/// <summary>
38+
/// The expose headers for CORS responses.
39+
/// </summary>
40+
public string[] ExposeHeaders { get; set; }
41+
42+
/// <summary>
43+
/// The maximum time in seconds that a browser can cache the CORS preflight response.
44+
/// A value of 0 means the property is not set.
45+
/// </summary>
46+
public int MaxAge { get; set; }
47+
}
48+
}

0 commit comments

Comments
 (0)