How to use Java CDK to define a DynamoDB-backed REST API with only AWS API Gateway – part 2

-

In the previous blog we discussed how you can define a no-code or low-code REST-API on top of a DynamoDB table. We used a high level construct from the AWS Solutions Constructs library, that defines all in one go. The limitation we ran into was that we couldn’t customise the output of the GET method in order to return only the (stored) JSON document. In this blog, we’ll replace the solution based on AWS Solutions Constructs library by a solution build with the standard CDK elements, that gives all the freedom we need.

As a starting point, we take the source code developed in part 1. Our first step is to refactor the code by replacing the “AWS Solutions Constructs” by constructs from the AWS Construct Library, and create exact the same deployment as in part 1. From there, we’ll improve the response of the GET method by adding a response template. Note that the complete sample code can be found on github.

Defining the major elements

For this refactoring, we only need to replace the content of the DynamoRestStack class. Just remove all code from the constructor, except for the call to super. We start with defining the major elements: the REST API and the DynamoDB table. Defining the REST API is pretty simple:

RestApi dynamoRestApi = new RestApi(this, "DynamoRest", RestApiProps.builder()
    .deployOptions(StageOptions.builder()
    .loggingLevel(MethodLoggingLevel.INFO)
    .build())
    .build());

Actually, it’s not much more then setting a meaningful name (“DynamoRest”) and enabling logging (in the previous version, the AWS Solution Construct enabled logging by default).
Defining the table is almost identical as before:

TableProps tableProps = TableProps.builder()
    .partitionKey(Attribute.builder()
         .name("id")
         .type(AttributeType.STRING)
         .build())
    .tableName("exerciseStats")
    .build();
Table dynamoDbTable = new Table(this, "exerciseStats", tableProps);

we only have to instantiate the Table explicitly (last line in the sample).

Allow access to db

Next, we need an IAM Role, in order to allow the API Gateway to access the DynamoDB table. This is typically something a higher level construct can arrange for us, which is why we didn’t have to bother while using the AWS Solution Constructs library in part 1. This is how the Role is defined in Java code:

role = new Role(this, "dynamorest", RoleProps.builder()
    .assumedBy(new ServicePrincipal("apigateway.amazonaws.com"))
    .build());

role.addToPolicy(new PolicyStatement(PolicyStatementProps.builder()
    .actions(List.of("dynamodb:Query", 
                     "dynamodb:PutItem", 
                     "dynamodb:UpdateItem"))
    .effect(Effect.ALLOW)
    .resources(List.of(dynamoDbTable.getTableArn()))
    .build()));

Note how the second part adds ALLOW permissions on the DynamoDB table (identified by its ARN) for the Query, PutItem and UpdateItem actions.

Defining the REST resource

This brings us to the interesting parts: setting up the resources and methods that make up the REST API. As before, we want the resource to be created by sending a POST request to root (“/”). Translated to CDK code: get the root resource and add a POST method:

IResource rootResource = dynamoRestApi.getRoot();
rootResource.addMethod("POST", createIntegration, 
                       MethodOptions.builder()
    .methodResponses(List.of(MethodResponse.builder()
        .statusCode("200")
        .build()))
    .build());

To make this compile, we first need to define how this method integrates with the back-end service (i.e. we need to define createIntegration). This is a bit more complicated, lets review each line of the following fragment:

Integration createIntegration = AwsIntegration.Builder.create()
    .action("PutItem")
    .service("dynamodb")
    .integrationHttpMethod("POST")
    .options(IntegrationOptions.builder()
        .credentialsRole(role)
        .requestTemplates(Map.of("application/json", createRequestTemplate))
        .integrationResponses(List.of(IntegrationResponse.builder()
            .statusCode("200")
            .build()))
        .build())
    .build();
  1. action (PutItem): the command that is sent to the back-end service
  2. service: definition of the back-end service. Use lowercase; even though the AWS console shows “DynamoDB”, that doesn’t work in CDK!
  3. integrationHttpMethod: the HTTP method that is used to communicate with the back-end service; always POST for DynamoDB (do not confuse with the HTTP method used for in the REST call itself)
  4. credentialsRole: the IAM role that will give the integration the necessary permissions
  5. requestTemplate: the template that defines the command being send to DynamoDB
  6. integrationResponses: the response(s) the REST call might return.

The (createRequestTemplate) is exactly the same template we used in part 1.

We’re nearly there. For the PUT method, the recipe is almost the same (with action = “UpdateItem”), except for the resource. To update the resource, we don’t want to PUT to root, but to the resource to be updated of course (e.g. /2021-05-31). So, instead of retrieving and using the root, we create the document resource first:

Resource doc = dynamoRestApi.getRoot().addResource("{id}");

The rest (pun intended) is pretty similar. The same holds for the GET method: in this case the action is “Query”. Refer to this commit for the complete solution at this stage.

Back where we started

The code is now functionally equivalent to the version created in part 1: if you run cdk deploy you can check the result in AWS Console or test it by “curling” a POST request.

The final part

Finally, we get at the part that made us start. Our aim is to write a template that will transform the response from DynamoDB into a proper REST API response. Let’s recall what DynamoDB serves us when issuing the Query command:

{"Count":1,
"Items":[ {
    "content": {
        "S": "{ date: \"2021-05-25\", exercise: 42}"
    },
    "id": {
        "S": "2021-05-25"
    }
} ],
"ScannedCount":1}

What we want to return is the content, but with the “S” (type indicator) removed. This transformation is achieved by this piece of code:

#set($inputRoot = $input.path('$'))
#if(!$inputRoot.Items.isEmpty())$inputRoot.Items[0].content.S
#end

The syntax is from the velocity templating language, which has been used in multiple Java web frameworks in the past. See API Gateway Developer guide for more info. It’s a bit cryptic, but you probably can guess what it does: if the “Items” element of the response is non-empty, it navigates to the first item, takes the content part and and then the “S” part. If the “Items” element is empty, it returns nothing. The template rendering is quite literal: you can add spaces, but they’ll end up in the result at the same spot; that’s why we skipped all spaces, even though spaces would improve readability ;-).

Done

Just expand the Integration element with this template:

.integrationResponses(List.of(IntegrationResponse.builder()
    .statusCode("200")
    .responseTemplates(Map.of("application/json", 
        "#set($inputRoot = $input.path('$'))\n" +
        "#if(!$inputRoot.Items.isEmpty())" +
        "$inputRoot.Items[0].content.S\n" +
        "#end\n"))
    .build()))

and cdk deploy the solution. To see the result, copy-paste the production URL from the CDK output (or look it up in the AWS Console) and query an item with

curl https://xxx-api.eu-west-1.amazonaws.com/prod/2021-05-25

Wrap up

Now we have transformed the code to use only standard CDK constructs (from the AWS Construct Library, it becomes even more clear that higher level constructs like the one we used in part 1, make life easier. However, as with all abstractions, it also hides details that would actually give us more insights how things work under the hood. I think the takeaway is that knowing both solutions improves your understanding and ensures you can always pick the right tool for the job. In this case, we had to use the lower level API to complete our solution in a proper way and avoid that a GET request would return implementations details about the underlying persistence mechanism.
Do not forget the cdk destroy the sample to avoid unwanted costs.