Create Lambda functions using the Annotation Framework

10 minute read

In a previous post we’ve seen how we can leverage the AWSLambdaSharpTemplate library and its templates to create a Lambda function that is testable and supports ASP.NET Core subsystems without mixing the business logic with the plumbing necessary to configure the function.

In the last paragraph of that post, I introduced the Annotation Framework, a framework that leverages a source generator to simplify the development of Lambda functions. In the same paragraph, I also said that the Annotation Framework was focusing exclusively towards backing HTTP/REST API services with API Gateway as front. However, I discovered that this was not entirely accurate and I took my time to play around with the latest preview of the Annotation Framework and see it for myself.

Since the introductory post written by the AWS team shows really well how to write functions to be used in a HTTP/REST API context, I’ll be focusing on creating functions meant to be invoked via the Lambda API.

The Annotation Framework is still in preview. Currently, the latest public version is 0.11.0. Here is the page for the NuGet package.

To make an easier comparison between the Annotation Framework and the AWSLambdaSharpTemplate programming model, I’ll be implementing a Lambda function that uses Nationalize.io to guess the nationality of a person given their name.

The AWS team offers a template to quickly create a solution that uses the Annotation Framework. For learning purposes, I’m ignoring the template and building the whole solution from scratch. In case you want to experiment with the template, you can look at the serverless.Annotations template available in the .NET CLI template package Amazon.Lambda.Templates.

Setting up the solution

We’ll start by creating the solution and the project for the Lambda function.

First, we create the directory that will contain the project.

$ mkdir src/

Then, we use the .NET CLI to create an empty solution file and a default .gitignore file

$ dotnet new sln
$ dotnet new gitignore

Then, we create a class library project in the src/ directory. This project will contain the Lambda function we want to deploy into AWS.

$ dotnet new classlib -n FindNationalityFunction -o ./src/FindNationalityFunction -f net6.0

Finally, we add the project to the solution

$ dotnet sln add ./src/FindNationalityFunction/

We can now build the solution to ensure that everything is set up correctly

$ dotnet build

Build succeeded.
    0 Warning(s)
    0 Error(s)

Adding the relevant packages

Now that the solution is in place, we can add the needed packages to the class library.

Let’s begin by adding the required Amazon Lambda packages.

$ dotnet add ./src/FindNationalityFunction/ package Amazon.Lambda.Core
$ dotnet add ./src/FindNationalityFunction/ package Amazon.Lambda.Serialization.SystemTextJson

Then, we need to add the packages for the various .NET extensions.

$ dotnet add ./src/FindNationalityFunction/ package Microsoft.Extensions.Http
$ dotnet add ./src/FindNationalityFunction/ package Microsoft.Extensions.Options
$ dotnet add ./src/FindNationalityFunction/ package Microsoft.Extensions.Options.ConfigurationExtensions
$ dotnet add ./src/FindNationalityFunction/ package Microsoft.Extensions.Configuration
$ dotnet add ./src/FindNationalityFunction/ package Microsoft.Extensions.Configuration.EnvironmentVariables
$ dotnet add ./src/FindNationalityFunction/ package Microsoft.Extensions.Logging
$ dotnet add ./src/FindNationalityFunction/ package Microsoft.Extensions.Logging.Console

Finally, we add the package containing the Annotation Framework. Since it’s still in preview, we will have to instruct the CLI to accept prerelease packages.

$ dotnet add ./src/FindNationalityFunction/ package Amazon.Lambda.Annotations --prerelease

If you followed all the steps above, your project file should look something like the following snippet

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Amazon.Lambda.Annotations" Version="0.11.0" />
    <PackageReference Include="Amazon.Lambda.Core" Version="2.1.0" />
    <PackageReference Include="Amazon.Lambda.Serialization.SystemTextJson" Version="2.3.0" />
    <PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
    <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" />
    <PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
    <PackageReference Include="Microsoft.Extensions.Options" Version="7.0.0" />
    <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="7.0.0" />
    <PackageReference Include="Microsoft.Extensions.Logging" Version="7.0.0" />
    <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="7.0.0" />
  </ItemGroup>

</Project>

Adding the helper types

Now that the project and its dependencies are taken care for, it’s time to focus on the source code.

Before we move on, let’s remove the Class1.cs file and add an empty file called Functions.cs.

In the empty file, now we can add the namespace declaration and decorate the assembly with an attribute to specify the serialization engine to use.

[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]

namespace FindNationalityFunction;

Then, we introduce the types that will be used to deserialize the response from Nationalize.io

public record Response(
    [property: JsonPropertyName("country")] Country[] Countries,
    [property: JsonPropertyName("name")] string Name
);

public record Country(
    [property: JsonPropertyName("country_id")] string CountryCode,
    [property: JsonPropertyName("probability")] double Probability
);

public record FindNationalityOptions
{
    public double MinimumThreshold { get; init; }
}

Now that we have added all the helper types, we can focus our attention on the juicy parts of the post.

Setting up the function

One of the key features of the Annotation Framework is the possibility to extract the configuration of the services used by the function in a stand-alone class. The source generator will use this class to configure the dependency injection system together with all other subsystems like configuration and logging.

All we need to do, is to create a class with a method named ConfigureServices and mark it with the attribute [LambdaStartup] like the following.

In the snippet below, we’re defining a Startup class, decorated with the [LambdaStartup] attribute, and use it to set up all the needed services.

[LambdaStartup]
public class Startup
{
    public IConfiguration Configuration { get; init; } = CreateConfiguration();

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddSingleton(Configuration);

        services.AddLogging(logging => logging.AddConsole());

        services.AddOptions();

        services.Configure<FindNationalityOptions>(Configuration.GetSection("FindNationality"));

        services.AddHttpClient("Nationality", http => http.BaseAddress = new Uri("https://api.nationalize.io"));

        services.AddTransient<HttpClient>(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("Nationality"));
    }

    private static IConfiguration CreateConfiguration()
    {
        var builder = new ConfigurationBuilder();

        builder.AddEnvironmentVariables();

        return builder.Build();
    }
}

It’s important to note that only the method ConfigureServices is required. We can structure the Startup class as we prefer. In the example above, I define a static method to setup the Configuration subsystem and use it to initialize a property. Then, we can use the value held by this property to configure the services as needed.

Finally, note that we have added a specific service registration for HttpClient. This line allows us to request an instance of HttpClient that is spawned from the configured factory configuration.

Writing the function

The Annotation Framework simplifies having multiple functions hosted in the same project. Each function needs to be a method marked with the attribute [LambdaFunction]. The source generator will take care of creating a Lambda function handler that properly invokes the method.

In this post, we’ll focus on adding a single function. This function will receive a name and return all countries above the given threshold returned by the Nationalize.io API.

public class Functions
{
    private readonly FindNationalityOptions _options;
    private readonly ILogger<Functions> _logger;

    public Functions(IOptions<FindNationalityOptions> options, ILogger<Functions> logger)
    {
        _options = options?.Value ?? throw new ArgumentNullException(nameof(options));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    [LambdaFunction]
    public async Task<List<Country>> GetCountriesAsync([FromServices] HttpClient http, string name)
    {
        _logger.LogInformation("Fetching countries for name {Name}", name);

        var response = await http.GetFromJsonAsync<Response>($"/?name={name}");

        return response?.Countries.Where(c => c.Probability >= _options.MinimumThreshold).ToList() ?? new List<Country>();
    }
}

To make the example more interesting, I’m using the [FromServices] parameter attribute to notify the source generator that the parameter should be fetched from the registered services and provided to the function at execution time. This feature is clearly inspired by ASP.NET Core and it makes easy to pass dependencies whose lifecycle should be limited to that of the function.

Serverless template

Now that the code is complete, we can save and build the project. The source generator included in the Annotation Framework library will take care of generating all the needed code to properly execute the function. Other than some C# classes, the generator produces a serverless.template file that fully describes the application.

This is the content of the serverless.template file generated when building this project.

{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Transform": "AWS::Serverless-2016-10-31",
  "Resources": {
    "FindNationalityFunctionFunctionsGetCountriesAsyncGenerated": {
      "Type": "AWS::Serverless::Function",
      "Metadata": {
        "Tool": "Amazon.Lambda.Annotations"
      },
      "Properties": {
        "Runtime": "dotnet6",
        "CodeUri": ".",
        "MemorySize": 256,
        "Timeout": 30,
        "Policies": [
          "AWSLambdaBasicExecutionRole"
        ],
        "PackageType": "Zip",
        "Handler": "FindNationalityFunction::FindNationalityFunction.Functions_GetCountriesAsync_Generated::GetCountriesAsync"
      }
    }
  },
  "Description": "This template is partially managed by Amazon.Lambda.Annotations (v0.11.0.0)."
}

This file can be used to deploy the function to AWS using CloudFormation.

Exploring the generated code

In the previous snippet, we can see that the handler of the GetCountriesAsync function is set to FindNationalityFunction::FindNationalityFunction.Functions_GetCountriesAsync_Generated::GetCountriesAsync.

Specifically, we can see that the handler is a function residing in a class named Functions_GetCountriesAsync_Generated.

Normally, the code generated is kept in memory but we can instruct the compiler to save to disk the generated files. This is very helpful when authoring a source generator, but we can use this feature to explore how the Annotation Framework works behind the scenes.

This feature can be enabled with a simple MSBuild property. For testing purposes we will set its value in the project file. Normally, we should avoid this.

<PropertyGroup>
    <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>

Alternatively, we can set the property value when building the project from the command line

$ dotnet build /p:EmitCompilerGeneratedFiles=true

Once we’ve built the function with the feature enabled, we can explore the content of the directory obj/Debug/net6.0/Amazon.Lambda.Annotations.SourceGenerators/Amazon.Lambda.Annotations.SourceGenerators.Generator/. This directory contains a file named Functions_GetCountriesAsync_Generated.g.cs.

We can look at the Functions_GetCountriesAsync_Generated class it contains to understand the code generated by the source generator.

For readability reasons, I’ll omit the namespaces of the classes involved in the snippets below.

First, let’s look at the constructor.

public Functions_GetCountriesAsync_Generated()
{
    var services = new ServiceCollection();

    services.AddSingleton<Functions>();

    var startup = new FindNationalityFunction.Startup();
    startup.ConfigureServices(services);
    serviceProvider = services.BuildServiceProvider();
}

We see that a ServiceCollection is created, then we initialize it with an instance of the Startup class. Finally, we use it to build an instance of IServiceProvider that is assigned to a field.

Next, we can give a look at the GetCountriesAsync method

public async Task<List<Country>> GetCountriesAsync(string name)
{
    using var scope = serviceProvider.CreateScope();
    var functions = scope.ServiceProvider.GetRequiredService<Functions>();

    var http = scope.ServiceProvider.GetRequiredService<HttpClient>();
    return await functions.GetCountriesAsync(http, name);
}

The first thing we can notice is that the parameters tagged with the [FromServices] attribute are not present in the generated method’s signature. Instead, an instance is retrieved from the service provider and passed to the original method.

Another important detail to notice is that the whole request is wrapped in a service provider scope. This helps with the management of the lifecycle of the dependencies.

Customizing the function

In the previous paragraph, we saw that the source generator creates a serverless template using the default values for some properties of the function. The [LambdaFunction] attribute can be used to customize those values.

For instance, we can set the function name to FindNationality and increase the memory size to 512 MB like so:

[LambdaFunction(Name = "FindNationality", MemorySize = 512)]
public async Task<List<Country>> GetCountriesAsync([FromServices] HttpClient http, string name)
{
  ...
}

In addition to name and memory size, the attribute provides a way to customize other aspects of the function such as its timeout, role, policies, and the preferred packaging strategy (i.e., zip file or Docker image). Here’s an example that sets the timeout to 10 seconds and adds the AWSLambdaBasicExecutionRole policy:

[LambdaFunction(Timeout = 10, Policies = new[] { "AWSLambdaBasicExecutionRole" })]
public async Task<List<Country>> GetCountriesAsync([FromServices] HttpClient http, string name)
{
  ...
}

Using the [LambdaFunction] attribute, we can fine-tune the configuration of our Lambda function to match our specific requirements.

Lastly, the [LambdaFunction] attribute can be used to customize other aspects of the Lambda function, such as role, policies, and the preferred packaging strategy. Here is an example of how to customize the previously defined Lambda function using the [LambdaFunction] attribute:

[LambdaFunction(Name = "FindNationality", MemorySize = 128, Timeout = 60, Role = "arn:aws:iam::123456789012:role/lambda-role", PackageType = "Image")]
public async Task<List<Country>> GetCountriesAsync([FromServices] HttpClient http, string name)
{
  ...
}

In this example, the Name, MemorySize, and Timeout properties are set as before, while the Role property sets the IAM role that AWS Lambda assumes when executing the function, and the PackageType property sets the preferred packaging strategy to be a Docker image.

In summary, the [LambdaFunction] attribute allows developers to customize various aspects of their AWS Lambda function while still benefiting from the Annotation Framework’s automatic dependency injection and ASP.NET Core subsystems support.

Recap and final thoughts

In this post, we explored how the AWS Lambda Annotations Framework helps create AWS Lambda functions that can support Dependency Injection and use other ASP.NET Core subsystems like configuration and logging. The Annotation Framework leverages the power of source generators to generate the necessary code and simplify the development experience.

We also saw how the [LambdaFunction] attribute can be used to customize various aspects of the function, including the function name, memory size, timeout, role, policies, and packaging strategy. This can be useful for adapting the function to specific use cases.

Overall, I believe the Annotation Framework holds great potential for simplifying the development process and making it easier for developers to write high-quality code.

While the Annotation Framework is currently closely tied to CloudFormation, it would be beneficial to have a more open and extensible system that can integrate with other CI/CD tools, thereby enabling developers to build and deploy their Lambda functions more seamlessly regardless of the used tool.

Support this blog

If you liked this article, consider supporting this blog by buying me a pizza!