Serverless API Development Made Easy: Using AWS Lambda Annotations for CRUD

Learn how to create a CRUD API Endpoint using AWS Lambda, API Gateway and the Annotations Framework. See how the Lambda Annotations framework does all the heavy lifting for us, and makes it easier to develop APIs.

Rahul Pulikkot Nath
Rahul Pulikkot Nath

Table of Contents

The Lambda Annotations Framework is a programming model that makes it easier to build AWS Lambda Functions using .NET.

The framework uses C# custom attributes and Source Generators to translate annotated Lambda functions to the regular Lambda programming model.

In a previous post, we learned how to get started with using the Annotations Framework and how it compares to using the default Lambda Programming model. The Annotations Framework removes boilerplate code required to integrate with API Gateway.

In this post, let’s learn how to set up a basic CRUD (Create, Read, Update, and Delete) Order API Endpoint using the Lambda Annotation Framework.

We will use DynamoDB as our data store for this example and we will also learn how to set up the DynamoDB Table and Role Permissions via the same CloudFormation template that the Annotations framework generates.

This article is sponsored by AWS and is part of my AWS Series.

Create and Update API Endpoint

Let's first create the Create Endpoint for our API.

Using the Annotations Framework, we can use the LambdaFunction and HttpApi attribute to decorate a .NET function to be a Lambda Entry point.

The endpoints accept an object of type Order which represents an Order in our domain.

We have a simple Order class with the OrderId as it's primary key.

To bind the Order item from the requests body, we can use the FromBody attribute on the function parameter. This is very similar to how we do when building ASP.NET Core APIs.

⚠️
Make sure the template path does not have a leading slash on it.

We also have specified a custom IAM Role Name as part of the Role parameter in the LambdaFunction attribute. More on this later.

[LambdaFunction(Role = "@OrdersApiLambdaExecutionRole")]
[HttpApi(LambdaHttpMethod.Post, template: "/orders")]
public async Task PostOrder([FromBody] Order order, ILambdaContext context)
{
    await _dynamodbContext.SaveAsync(order);
}

public class Order
{
    public string OrderId { get; set; }
    public decimal Total { get; set; }
    public DateTime CreatedDate { get; set; }
}

Using the DynamoDBContext we can save the Order into the table. If you are new to DynamoDB I highly recommend checking the below blog post to get started.

Amazon DynamoDB For The .NET Developer
This blog post is a collection of other posts that covers various aspects of Amazon DynamoDB and other services you can integrate with when building serverless applications.

For the DynamoDBContext save to work, we need to make sure the Table is set up and the Lambda Function has appropriate access to the table.

Setting up DynamoDB Table and IAM Roles via CloudFormation

As part of the Lambda Annotations Framework, it auto-generates a serverless.template CloudFormation template file.

However, you can manually add Resources to the template file.

In our case, we need to set up the DynamoDB Table and the Lambda Access Roles with the appropriate permission to talk to DynamoDB Table.

To set up the Order DynamoDB table, let's add the below to the Resources section of the template.

"OrderTable": {
  "Type": "AWS::DynamoDB::Table",
  "Properties": {
    "AttributeDefinitions": [
      {
        "AttributeName": "OrderId",
        "AttributeType": "S"
      }
    ],
    "KeySchema": [
      {
        "AttributeName": "OrderId",
        "KeyType": "HASH"
      }
    ],
    "TableName": "Order",
    "BillingMode": "PAY_PER_REQUEST"
  }
}

It sets up the DynamoDB table with a specific name and also the Keys for the table.

For setting up the Lambda IAM Role, let's use the below template in the Resources section.

It sets up full access to DynamoDB Order Table. Note I am using the CloudFormation function Fn::GetAtt to get the ARN of the DynamoDB Table created in the same template file.

"OrdersApiLambdaExecutionRole": {
  "Type": "AWS::IAM::Role",
  "Properties": {
    "AssumeRolePolicyDocument": {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Principal": {
            "Service": "lambda.amazonaws.com"
          },
          "Action": "sts:AssumeRole"
        }
      ]
    },
    "ManagedPolicyArns": [
      "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
    ],
    "Policies": [
      {
        "PolicyName": "OrderApiDynamoDBAccessPolicy",
        "PolicyDocument": {
          "Version": "2012-10-17",
          "Statement": [
            {
              "Effect": "Allow",
              "Action": "dynamodb:*",
              "Resource": {
                "Fn::GetAtt": ["OrderTable", "Arn"]
              }
            }
          ]
        }
      }
    ]
  }
}

The LambdaFunction attribute specifies the Role property to use the new IAM role that we just created above.

We use the name Role = "@OrdersApiLambdaExecutionRole" to refer to the IAM role created in the template file. The @ prefix refers to look up for the name from the CloudFormation template.

This generates the below section to the CloudFormation template for the Lambda Function

"LambdaAnnotationSampleOrderApiFunctionPostOrderGenerated": {
  "Type": "AWS::Serverless::Function",
  "Metadata": {
    "Tool": "Amazon.Lambda.Annotations",
    "SyncedEvents": [
      "RootPost"
    ]
  },
  "Properties": {
    "Runtime": "dotnet6",
    "CodeUri": ".",
    "MemorySize": 256,
    "Timeout": 30,
    "Role": {
      "Fn::GetAtt": [
        "OrdersApiLambdaExecutionRole",
        "Arn"
      ]
    },
    "PackageType": "Zip",
    "Handler": "LambdaAnnotationSample.OrderApi::LambdaAnnotationSample.OrderApi.Function_PostOrder_Generated::PostOrder",
    "Events": {
      "RootPost": {
        "Type": "HttpApi",
        "Properties": {
          "Path": "/order",
          "Method": "POST"
        }
      }
    }
  }
}

You can deploy the template from the IDE or using the AWS CLI, which will create the Lambda Function, the IAM Role, the API Endpoint and wire up the integration for the route to the Lambda Function on the API Gateway.

Read API Endpoint

To set up the Read API Endpoint, all we need to do is create another Function in the same class and add the LambdaFunction and HttpApi attribute. Since this is a GET, it specifies the appropriate LambdaHttpMethod on it.

[LambdaFunction(Role = "@OrdersApiLambdaExecutionRole")]
[HttpApi(LambdaHttpMethod.Get, "/orders/{orderId}")]
public async Task<Order> GetOrder(string orderId, ILambdaContext context)
{
    return await _dynamodbContext.LoadAsync<Order>(orderId);
}

The function takes in the orderId as the parameter, which is bound from the path parameter in the request URL.

You can use the DynamoDBContext to retrieve the item from the Table and return the Order item.

Lambda Annotations will automatically convert this into JSON representation and send it as part of the HTTP response body.

Delete API Endpoint

Very similar to the Get API Endpoint, we can add a new Function for the Delete endpoint.

[LambdaFunction(Role = "@OrdersApiLambdaExecutionRole")]
[HttpApi(LambdaHttpMethod.Delete, "/orders/{orderId}")]
public async Task DeleteOrder(string orderId, ILambdaContext context)
{
    await _dynamodbContext.DeleteAsync<Order>(orderId);
}

It takes the orderId from the Path parameter and uses that to delete the item from the DynamoDB table.

Annotations Framework Generated Code

The Lambda Annotations Framework uses .NET Source Generators to generate the boilerplate code to interact with the API Gateway.

Below is a screenshot of the DLL from JetBrains dotPeek application, showing the generated code.

Lambda Annotations Framework generated code using Source Generator.

As you can see, for each of the functions attributed with LambdaFunction it creates a new class, that wraps around the original function and adds the boilerplate code to talk with API Gateway.

This makes it easier for us developers to focus just on the business logic and not worry about how to integrate with the API Gateway.

You can find the full code sample here.

AWS