AWS API Gateway + Lambda — I’m going to need more coffee for this

If you read back over my adventures with AWS Lambda functions over the past few months, you may not be surprised to see my attention has turned to wanting to explore bolting an API in front of a Lambda function.

There is some method in this madness: while I’m happy with the Lambda based pipeline for processing and archiving photos, I really would like to put a web interface on it to make it easy to browse through the photos. My initial thought was to just periodically build static web pages (since I’m organising by date), but on reflection decided that was an ugly idea. So… time to work out how to put together a serverless API.

I’m going to say up front that AWS have made this harder than it should be. The documentation and tutorial material around this could be a lot better. There’s nothing actually wrong about the materials, but they are opaque and scattered. I think this is compounded by two things. First, there are two versions of the AWS API Gateway, each supporting both HTTP and WebSockets, with slightly different semantics and configuration. So a lot of the (fragmentary) tutorial materials are talking about WebSockets instead of HTTP, or talking about the version 1 instead of version 2 product, or mixing all of these together. Second, there are subtle semantic differences between the exposed API for managing AWS API Gateway, and the experience using the console. Frankly, I think the console is a very good interface for setting up an API Gateway, with a logical flow that makes sense. It just doesn’t line up very well with the AWS API for it, and as a corollary, with Terraform.

Now, if you look back at the things I’ve been writing up, you’ll notice that I’m a fan of infrastructure-as-code, and Terraform. So even for a simple exploration like this, my preference is to build the solution out with Terraform. There’s a lot of talk about the benefits of infrastructure-as-code for repeatability, or for security, or for speed of development, but for me there’s an additional purpose: once you are used to reading it, the Hashicorp HCL used in Terraform code makes for a good record of how the infrastructure was configured. Clicking around on the console doesn’t provide that record, and raw API invocations obscure the configuration among all the glue code to apply and manage the configuration.So. Let’s get on with it. My initial experiment is a very simple API. Some months ago I wrote some Java code to explore using AWS CodeCommit and AWS CodeArtifact (see https://levelup.gitconnected.com/aws-codeartifact-with-maven-further-adventures-with-serverless-4ff07fd69e1b), so the logical extension to this was to wrap that library up in a Lambda, and front it with an API.There are three parts to this: Terraform code to define the API, Terraform code to define the Lambda, and some Java code to define the Lambda. There’s also the infrastructure I’ve set up to perform serverless builds of my Lambda code, but I won’t spend time on it here. (If you are interested head on over to: https://rahook.medium.com/serverless-code-pipelines-on-aws-30dfc91889c6). I also won’t spend much time on the Terraform code to define the Lambda, because I’ve written that up in the past, other than a small bit of wiring between the API Gateway and Lambda.It’s best to start with the AWS API Gateway. This is covered off in the AWS Documentation, but that’s a maze of twisty passages, all alike. There are four basic parts to the definition, excluding configuration of a custom domain name and configuration of some sort of authentication/authorisation scheme.First, there’s the API Gateway itself. There’s not much to define here, it’s mainly a statement of existence, and becomes the root of all the other configuration:

resource “aws_apigatewayv2_api” “cidrapi” { name = local.name description = “description here…” version = “1.0.0” protocol_type = “HTTP” disable_execute_api_endpoint = false tags = merge({ “Name” = local.name }, var.tags) }

The API Gateway gets a name, and a description, and a type — specifying disable_execute_api_endpoint is the default anyway, but I’ve left it there as a reminder for myself to disable the default endpoint when I want to add a custom domain in it’s place.Next, you have one or more integrations that specify what the backend will be.

resource "aws_apigatewayv2_integration" "cidrapi" {
  api_id           = aws_apigatewayv2_api.cidrapi.id
  integration_type = "AWS_PROXY"  connection_type        = "INTERNET"
  description            = "cidrapi service"
  integration_method     = "POST"
  integration_uri        = aws_lambda_function.cidrapi.invoke_arn
  payload_format_version = "2.0"
  timeout_milliseconds   = 30000
}

There is a lot that can be configured on these integrations, even for a simple Lambda like this one. Most of this is straightforward, but the integration_method and integration_type are poorly explained in the documentation. For a Lambda, it’s not apparent that only POST is supported, and that this represents the way that the gateway invokes the lambda, and doesn’t have any relationship to the exposed API methods, which may be POST, GET, DELETE, or whatever you like. The use of the word “proxy” is a bit misleading as well, but makes sense if you think of the API Gateway as more than just a simple facade.It’s not well articulated in their documentation and examples, but the API Gateway, particularly the newer versions, is a very impressive and flexible piece of technology. The various introductory materials and tutorials may lead you to think that the API Gateway is limited to being a way to setup a facade in front of some back-end service. Instead, it’s useful to think of it as a web server in it’s own right (somewhat like NGINX) which can directly respond to incoming requests, pass requests to backend services or external URLs, manage authentication and TLS termination, and do sophisticated traffic filtering, transformation and shaping.In our simple Lambda case, we truly are just using API Gateway as a proxy that passes incoming requests to the Lambda in a form that the Lambda can digest (via a POST), and does the reverse translation out to the caller. This is going to be a very (very) common model for most API+Lambda scenarios, and the architecture they have built provides very nice decoupling between the exposed API and the Lambda backend code.We have an API, and we have one or more integrations — next step is to define routesBroadly speaking these define what operations the API will respond to, and how to deal with that operation. Again, there’s a lot of flexibility here around defining authorisation, and some facilities for transforming request parameters, but for our simple case, we can just pass the requests along:

resource "aws_apigatewayv2_route" "regions" {
  operation_name = "listRegions"
  api_id         = aws_apigatewayv2_api.cidrapi.id
  route_key      = "GET /regions"
  target         =
     "integrations/${aws_apigatewayv2_integration.cidrapi.id}"
}resource "aws_apigatewayv2_route" "services" {
  operation_name = "listServices"   
  api_id         = aws_apigatewayv2_api.cidrapi.id
  route_key      = "GET /services"
  target         =
     "integrations/${aws_apigatewayv2_integration.cidrapi.id}"
}resource "aws_apigatewayv2_route" "cidr" {
  operation_name = "listCidr"
  api_id         = aws_apigatewayv2_api.cidrapi.id
  route_key      = "GET /cidr"
  target         =
     "integrations/${aws_apigatewayv2_integration.cidrapi.id}"
}resource "aws_apigatewayv2_route" "cidr_region" {
  operation_name = "listCidrByRegion"
  api_id         = aws_apigatewayv2_api.cidrapi.id
  route_key      = "GET /cidr/{region}"
  target         =
     "integrations/${aws_apigatewayv2_integration.cidrapi.id}"
}resource "aws_apigatewayv2_route" "cidr_service" {
  operation_name = "listCidrByService"
  api_id         = aws_apigatewayv2_api.cidrapi.id
  route_key      = "GET /cidr/{region}/{service}"
  target         =
     "integrations/${aws_apigatewayv2_integration.cidrapi.id}"
}

In my case, I define 5 operations, and hand them off to my integration to deal with. A few things to note here. I could have had a single route using $default as the route_key, but that would potentially require my Lambda to need more logic to understand the incoming request. Additionally, by using specific route_key, the API Gateway itself takes care of rejecting incoming requests that I’m not interested in, rather than forcing the Lambda invocation to reject those requests — in other words, random calls hammering the exposed API will not result in unnecessary Lambda invocations. Finally, you may have noticed that each route could have a different target, which is highly likely for a non-trivial application.The documentation for the route keys is pretty confusing, and it’s not clear that it’s possible to define expected path variables (e.g. {region} in the above examples) as well as “catch all” variables that more-or less represent a wildcard match for the end of the URL (they use {proxy+} in their documentation, just to add to the confusion). These path variables will show up as an ordered list of path variables available to your Lambda code. If you used a route key like

GET /{proxy+}

Then any request path would be accepted, and all the elements of the path would eventually show up as an ordered list of path elements in your Lambda handler. This might seem convenient, but it makes the Lambda code more complex. Define fairly precise routes in the API Gateway, and let your Lambda code be a lot simpler and dumber.So far we have the API Gateway, one or more integrations, some routes. The final missing part is the rather confusingly named “stage”. I have a suspicion that the design and intention of this in AWS changed partway through implementation, and the documentation is quite terse and uninformative. It may have been that they were thinking that this was a way to expose different versions of the API, or to expose development/test/production versions (please do not do this — use different accounts for those stages!) but this gets muddled by allowing direct specification of Lambdas here (bypassing the integration configuration), and insertion of additional parameters into the request (bypassing the route configuration). Finally, you can create a default stage using the magic name $default, but you probably shouldn’t. I need to spend more time in this particular confusing corner of the service.Despite the confusion around the stage, you can’t get away from not using it — without a defined stage, the API does not get published and made available for invocation. Also, the stage is where you can optionally specify logging, monitoring and (especially) rate limiting:

resource "aws_apigatewayv2_stage" "v1" {
  api_id      = aws_apigatewayv2_api.cidrapi.id
  name        = "v1"
  description = "V1 for ${local.name}"
  auto_deploy = true  default_route_settings {
    throttling_burst_limit = 50
    throttling_rate_limit  = 100
  }
  tags = merge({ "Name" = local.name, "Version" = "V1" }, var.tags)
}

Since this API is going to be on the internet (albeit with an obscured and random URL), I want to seriously limit how often my Lambda gets invoked. The last thing I need this month is an unexpected bill from AWS! You will also notice I set auto_deploy to true — if I set this to false, I would then also have to specify a deployment. While the intent of this is to allow me to control when the API is published, it’s not something I need at this time. The API is published as soon as it’s defined, and updated whenever it’s definition changes.After all that, it means that we can use the invoke_url property of our stage to invoke our Lambda. You will remember I have a route with a route key like

GET /cidr/{region}/{service}

And I have defined the stage with the name “v1”. The resulting URL I can invoke then with a GET becomes:

https://mqciw5p4x8.execute-api.eu-west-2.amazonaws.com/v1/cidr/eu-west-2/EBS

(That might work for you, but I don’t guarantee that the URL has not changed!)One final thing — we need to allow AWS API Gateway to invoke our Lambda. I always trip over this, probably because the Lambda permissions are not managed through the AWS IAM service. So the last part is:

resource "aws_lambda_permission" "allow_api" {
  for_each = toset([
    "services",
    "cidr",
    "cidr/{region}",
    "cidr/{region}/{service}",
    "regions"
  ])  statement_id_prefix = "ExecuteByAPI"
  action              = "lambda:InvokeFunction"
  function_name       = aws_lambda_function.cidrapi.function_name
  principal           = "apigateway.amazonaws.com"
  source_arn          = "${aws_apigatewayv2_api.cidrapi.execution_arn}/*/*/${each.key}"
}

This is not hugely complex, although the need to specify the source_arn with respect to the execution_arn is a bit clunky. In this case I am creating independent permissions for each of the potential incoming sources, however I could have created a single permission with a wildcard instead of the ${each.key}, i.e.

resource "aws_lambda_permission" "allow_api" {
  statement_id_prefix = "ExecuteByAPI"
  action              = "lambda:InvokeFunction"
  function_name       = aws_lambda_function.cidrapi.function_name
  principal           = "apigateway.amazonaws.com"
  source_arn          = "${aws_apigatewayv2_api.cidrapi.execution_arn}/*/*/*"
}

I opted not to do this however, as I prefer to apply the principle of least privilege from the beginning, rather than trying to add it later when I might forget to. If your Lambda is supporting a great number of request URLs this approach may not be for you, but then again if your Lambda is doing several unrelated tasks, you should be breaking it up.That’s it on the Terraform side, let’s consider the Lambda code. The Lambda SDK is a delight to work with, and the definition of the invocation handler is very straightforward.I will say though that I’m not entirely happy using Java for AWS Lambda. The API is distinctly sluggish when the Lambda instances have not yet been warmed up, and it takes an appreciable number of seconds to get a response while the instance is launched, and Java does its start up:

$ time curl https://mqciw5p4x8.execute-api.eu-west-2.amazonaws.com/v1/cidr/eu-west-2/EBS["18.168.37.136/29","18.168.37.144/30"]curl   0.02s user 0.01s system 0% cpu 26.383 total

Once the instance is running, response times are fine, but that first hit is a killer. This has not been evident in any of the experiments I have done with Go, or indeed with Python.Leaving that aside, the handler code is simple. There are some imports you need:

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import com.amazonaws.services.lambda.runtime.events.APIGatewayV2HTTPEvent;
import com.amazonaws.services.lambda.runtime.events.APIGatewayV2HTTPResponse;

And of course the relevant Maven dependencies (if you insist on using Gradle, you’re on your own):

<dependency>
  <groupId>com.amazonaws</groupId>
  <artifactId>aws-lambda-java-core</artifactId>
  <version>1.2.1</version>
</dependency>

<dependency>
  <groupId>com.amazonaws</groupId>
  <artifactId>aws-lambda-java-events</artifactId>
  <version>3.8.0</version>
</dependency>

The class definition is an implementation of RequestHandler:

public class Handler implements RequestHandler<APIGatewayV2HTTPEvent, APIGatewayV2HTTPResponse> {
}

That requires us to implement a single function:

@Override
public APIGatewayV2HTTPResponse handleRequest(final APIGatewayV2HTTPEvent event, final Context context) {
}

Pretty obviously the APIGatewayV2HTTPEvent and APIGatewayV2HTTPResponse wrap up the request and response respectively. The Context object contains details about the Lambda and it’s environment, like the function name and ARN, but it also gives you access to a LambdaLogger:

context.getLogger().log("this message shows up in the console")

If the Lambda is wired to CloudWatch, then the log messages go there, otherwise they go to the Lambda console. And you don’t have to know any of that when you are writing the code. Do note though that when you are testing the handler, you need to inject a mock Context, and it’s handy to provide a mock LambdaLogger as well to log to your local logging environment during testing. The AWS Lambda Developer Guide contains useful examples of this.The incoming APIGatewayV2HTTPEvent contains quite a lot of stuff you might need to handle the request, but don’t let it overwhelm you. An example of an incoming request, rendered as JSON is:

{
    "version": "2.0",
    "routeKey": "GET /cidr/{region}/{service}",
    "rawPath": "/v1/cidr/eu-west-2/S3",
    "rawQueryString": "ipv6=true",
    "headers": {
        "accept": "*/*",
        "content-length": "0",
        "host": "mqciw5p4x8.execute-api.eu-west-2.amazonaws.com",
        "user-agent": "curl/7.64.1",
        "x-amzn-trace-id": "Root=1-60a7a07d-39030559613c7c1754c2ff6d",
        "x-forwarded-for": "89.36.68.26",
        "x-forwarded-port": "443",
        "x-forwarded-proto": "https"
    },
    "queryStringParameters": {
        "ipv6": "true"
    },
    "pathParameters": {
        "region": "eu-west-2",
        "service": "S3"
    },
    "isBase64Encoded": false,
    "requestContext": {
        "routeKey": "GET /cidr/{region}/{service}",
        "accountId": "304388919931",
        "stage": "v1",
        "apiId": "mqciw5p4x8",
        "domainName": "mqciw5p4x8.execute-api.eu-west-2.amazonaws.com",
        "domainPrefix": "mqciw5p4x8",
        "time": "21/May/2021:11:58:53 +0000",
        "timeEpoch": 1621598333192,
        "http": {
            "method": "GET",
            "path": "/v1/cidr/eu-west-2/S3",
            "protocol": "HTTP/1.1",
            "sourceIp": "89.36.68.26",
            "userAgent": "curl/7.64.1"
        },
        "requestId": "frYDkj2TrPEEMWw="
    }
}

Fortunately, you don’t have to deal with either a chunk of JSON, or all those details, as the request/response objects provide useful methods for you.For my purposes I need to see the HTTP path that has been called, the path parameters (if any) and the query parameters (if any). Fetching the path? Simple (but note that it excludes and query parameters):

String rawPath = event.getRawPath();--> "/v1/cidr/eu-west-2/S3"

Testing if there’s a query parameter “ipv6=true” is simple as well, as getQueryStringParameters() gives us a Map<String, String>

boolean ipv6 = Boolean.parseBoolean(
    event.getQueryStringParameters()
         .getOrDefault("ipv6", "false")
);--> false

And the same goes for the path parameters. Do I have a “service” path parameter?

String service = event.getPathParameters()
    .getOrDefault("service", null);--> "S3"

This is enough for me to call my library to calculate a response. Building the response is simple (note that I am using Google’s GSON to serialise to JSON):

Gson gson = new GsonBuilder().setPrettyPrinting().create();responseData = <<calling my library here>>return APIGatewayV2HTTPResponse.builder()
        .withStatusCode(200)
        .withHeaders(Map.of("Content-Type","application/json"))
        .withBody(gson.toJson(responseData))
        .build();

Or alternately if I want to build an error response:

return APIGatewayV2HTTPResponse.builder()
        .withStatusCode(503)
        .withHeaders(Map.of("Content-Type", "application/json"))
        .withBody("{}").build();

In this case, I still return an empty JSON object, just so there is consistency between the error response and the good response.There’s a lot to like about AWS API Gateway, particularly the newer version. It combines a lot of power, and a lot of flexibility, with the benefits of being easy to setup, deploy and manage. Combining it with Lambda functions leads to an architecture that takes away a lot of the hassles of a traditional API+Backend Service solution. For a lot of use cases it’s an ideal solution, and so it’s disappointing that the existing documentation and tutorial materials is poor enough to make the learning curve needlessly steep.

To find out more about software engineering, click here.

Similiar Articles

JOIN THE COMMUNITY

Sign up today for monthly newsletters containing:

  • News and insights from your industry
  • Relevant thought leadership articles
  • Engaging video content
  • Notifications of our upcoming events
  • Networking opportunities with C-Suite leaders