.NET Lambda Annotations and Terraform

Ryan Cormack
6 min readDec 9, 2023

--

AWS is a great platform for running so many workloads across so many languages. Sometimes it can be hard to know how to get started when there is such a vast array of tools at your disposal. In this blog post I’ll look at how you can quickly and easily get started building .NET Web APIs on Lambda using the AWS .NET Lambda Annotations Framework and Terraform.

The .NET Annotations Framework went generally available in mid 2023 but had been in preview for many months before that. It provides tooling and out the box guidance to make writing Lambda Functions much more idiomatic to .NET engineers. This allows you to easily take advantage of some of the great improvements being made to the .NET ecosystem as well as leverage the great scalability, cost and performance of AWS Lambda.

Out of the box the annotations framework incorporates the standard .NET dependency injection pattern and SAM, an abstraction on top of AWS’s Cloudformation Infrastructure as Code language. It also makes heavy use of .NET Source Generators that were introduced with .NET 6. The provided tooling makes it really easy to get going with Cloudformation, but it may not be so obvious how to integrate it with Terraform, a commonly used and cloud agnostic IaC tool.

Because the framework heavily leverages source generators to build or “generate” source code, the AWS team have been able to then build Cloudformation and SAM templates based on these conventions. This doesn’t happen automatically for Terraform, so it’s important to be aware of some of the changes that happen at compile time. First lets dive into the what’s happening in the Annotations source generator using my example:

The usual method signature for an AWS Lambda function handling an API Gateway request is:

public ApiGatewayProxyResponse Handler(ApiGatewayProxyRequest request, ILambdaContext context) {
//implementation
}

Usually we’d then expected to deserialise our the request.Body property ourselves and then convert the response object to something that resembled an ApiGatewayProxyResponse object, rather than something closer to an Http Response. We may also have a constructor that looks like:

private readonly IDynamoDbClient _ddb;
public Handler() {
_ddb = new AmazonDynamoDbClient();
// rest
}

There’s a few things going on here that isn’t ‘normal’ for traditional .NET Web APIs, including the lack of DI. To plumb this up to a Terraform’d Lambda Function we’d want a Terraform block that looks like:

resource "aws_lambda_function" "lambda" {
filename = "../package.zip"
function_name = "dotnet-lambda-annotations"
handler = "RyanCormack.Lambda.Annotations::RyanCormack.Lambda.Annotationst.Entry::Handle"
runtime = "dotnet6"
}

We’re defining out code package and the handler entry point. However, when we use the Source Generators, our entry point class is auto generated for us based on the code we have defined and annotated with the Lambda framework. To help us easily use our .NET paradigms the framework lets us define our DI container in a Startup.cs class:

[LambdaStartup]
public class Startup {
public void ConfigureServices(IServiceCollection services)
{
services.AddAWSService<IAmazonDynamoDB>();
services.AddAWSService<IAmazonSimpleNotificationService>();
services.AddSingleton<IOrderMapper, OrderMapper>();
}
}

The LambdaStartup attribute is from the Amazon.Lambda.Annotations package and tells our compiler to use this class for the source generation as our Startup class. The method signature in here is driven by convention and we get access to the usual IServiceCollection where we can define all our dependencies and their lifecycles.

Our function entry point gets similary decorated:

[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.SourceGeneratorLambdaJsonSerializer<SerialisationContext>))]
namespace RyanCormack.Lambda.Annotations;

public class Entry
{
private readonly IOrderMapper _mapper;
private readonly IAmazonDynamoDB _dynamoDb;
private readonly IAmazonSimpleNotificationService _sns;

public Entry(IOrderMapper mapper, IAmazonDynamoDB dynamoDb, IAmazonSimpleNotificationService sns)
{
_mapper = mapper;
_dynamoDb = dynamoDb;
_sns = sns;
}

[LambdaFunction]
[RestApi(LambdaHttpMethod.Post, "/orders/{userId}/create")]
public async Task<IHttpResult> Handle([FromBody] CreateOrderRequest request, string userId, ILambdaContext context)
{
//implentation
}
}

In the Startup class above I’ve registered my dependencies, then in the Entry class I’m injecting these and using them in my Handle function. The Handle function’s signature has also changed. Rather than taking the ApiGatewayProxyRequest and parsing that all myself, I’m decorating my method with the LambdaFunction attribute and defining it as a RestApi. Both of these come from the Lambda Annotations framework package. Then I’m defining that it’s an Http Post method with a route. These properties are used by the framework to help build the SAM template but they’re also used for the source generation to help with model binding and validation. The method signature is using the [FromBody] attribute to deserialise a CreateOrderRequest, from the http post body, then a userId string from the path URL and then finally our usual ILambdaContext. The source generation in the framework takes care getting these properties from the ApiGatewayProxyRequest for us from various parts of that object and uses System.Text.Json to deserialise them (using source generators again if you define it in the LambdaSerialiser).

So there’s a lot of source generation going on here. What does it actually generate? We can add the

<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>

to our csproj file to emit the source code that is generate by the Source Generator, as well as the DLL binaries.

Entry_Handle_Generated.cs
private readonly ServiceProvider serviceProvider;

public Entry_Handle_Generated()
{
SetExecutionEnvironment();
var services = new ServiceCollection();

// By default, Lambda function class is added to the service container using the singleton lifetime
// To use a different lifetime, specify the lifetime in Startup.ConfigureServices(IServiceCollection) method.
services.AddSingleton<Entry>();

var startup = new RyanCormack.Lambda.Annotations.Startup();
startup.ConfigureServices(services);
serviceProvider = services.BuildServiceProvider();
}

We’ve got something similarly named as our Entry.cs class, but with the _Generated suffix. It’s using our defined Startup.cs class and creating a singleton of our actual Entry.cs class.
Then we have our generated Handle method:

public async System.Threading.Tasks.Task<System.IO.Stream> Handle(Amazon.Lambda.APIGatewayEvents.APIGatewayHttpApiProxyRequest __request__, Amazon.Lambda.Core.ILambdaContext __context__)
{
// implementation
}

Here we’ve got our usual ApiGatewayProxyRequest object and our ILambdaContext. The Annotation Framework has generated this method for us and will use our own Handle method to fill out the implementation.

using var scope = serviceProvider.CreateScope();
var entry = scope.ServiceProvider.GetRequiredService<Entry>();

var validationErrors = new List<string>();

var request = default(RyanCormack.Lambda.Annotations.CreateOrderRequest);
try
{
request = System.Text.Json.JsonSerializer.Deserialize<RyanCormack.Lambda.Annotations.CreateOrderRequest>(__request__.Body);
}
catch (Exception e)
{
validationErrors.Add($"Value {__request__.Body} at 'body' failed to satisfy constraint: {e.Message}");
}

var userId = default(string);
if (__request__.PathParameters?.ContainsKey("userId") == true)
{
try
{
userId = (string)Convert.ChangeType(__request__.PathParameters["userId"], typeof(string));
}
catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException)
{
validationErrors.Add($"Value {__request__.PathParameters["userId"]} at 'userId' failed to satisfy constraint: {e.Message}");
}
}

The generated source code is using the request.Body object and the request.PathParameters object to extract out and deserialise our values for us then performing validation on them. This is closely mimicking the process that a typical ASP.NET Web API would follow, but we don’t have to worry about it. If we have any errors:

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();
System.Text.Json.JsonSerializer.Serialize(errorStream, errorResult);
return errorStream;
}

The framework takes care of formatting a 400 response and returning that for us. Then finally we get to our implementation:

var httpResults = await entry.Handle(request, userId, __context__);
HttpResultSerializationOptions.ProtocolFormat serializationFormat = HttpResultSerializationOptions.ProtocolFormat.HttpApi;
HttpResultSerializationOptions.ProtocolVersion serializationVersion = HttpResultSerializationOptions.ProtocolVersion.V2;
var serializationOptions = new HttpResultSerializationOptions { Format = serializationFormat, Version = serializationVersion };
var response = httpResults.Serialize(serializationOptions);
return response;

Since we have our method body object and our userId from the path parameter the framework is able to call our Entry class’s Handle method passing in the parameters we’ve defined. The Framework then takes care of formatting our response object to something compatible with API Gateway. 🎉

Given the source generation that happens under the hood, we do however need to make sure we reflect that in our Terraform definition. Because the _actual_ entry point isn’t the same as the one we define (the actual entry point is the source generated one) we need to make sure we tell the Terraform configuration what the assembly and method is called:

resource "aws_lambda_function" "lambda" {
filename = "../package.zip"
function_name = "dotnet-lambda-annotations"
handler = "RyanCormack.Lambda.Annotations::RyanCormack.Lambda.Annotationst.Entry_Handle_Generated::Handle" #Class is build from a source generator
runtime = "dotnet6"
}

The only difference here is the handler line. I’m defining this as the source generated class and method rather than the one I’ve defined. That’s actually the only difference we need to make. The Annotation Framework is able to do this automatically for SAM, but for Terraform we need to make sure we’re changing this ourselves.

In this post I’ve looked at how we can use the Lambda Annotations Framework to make it really easy to get going with .NET Web API functions on AWS Lambda and how we can integrate this with Terraform.

--

--

Ryan Cormack

Serverless engineer and AWS Community Builder working on event driven AWS solutions