HTTP APIs with Simple Lambda Functions

API Gateway has always been somewhat of a beast to configure, and while its incredibly flexible its also far more hassle than most people want or need. Terraform was also completely the wrong tool for deploying these because of the sheer number of resources required and the need to deploy APIs to a stage just didn’t quite fit with the way Terraform works.

The new HTTP APIs, whilst almost completely un-google-able, are an amazingly simple was to expose Lambda Functions to the outside world. They’re easy to deploy, simple to define, have great 404 handling, built in JWT authentication, and an easy CORS config. Apart from the (currently!) missing service integrations, there’s literally nothing to dislike about them!

About The $default Route

HTTP APIs allow you to add a special route called $default which will essentially capture anything that doesn’t already have a route handler attached to it. It sounds like an ideal fallback route, but there is one caveat in that it will also capture CORS OPTIONS requests in some situations, which is definitely not what what I was expecting to happen with it.

The documentation doesn’t state this particularly clearly, but instead of using a $default route, instead you should use a /{proxy+} route to capture anything without a handler, which still allows the CORS requests to hit the HTTP APIs built-in handler without you needing to write any code.

Defining the API

Creating an API is pretty simple and allows you to create the CORS config at the same time:

resource "aws_apigatewayv2_api" "api" {
  name          = "v2-http-api"
  protocol_type = "HTTP"

  cors_configuration {
    allow_credentials = false
    allow_headers     = ["*"]
    allow_methods     = ["*"]
    allow_origins     = ["*"]
    expose_headers    = ["*"]
    max_age           = 3600
  }
}

This resource creates an HTTP type API in API Gateway, and adds an extremely open CORS policy. You would likely want to tie this down a little more, but this policy makes the API open everywhere which is easy for testing.

The Stage (With Logging)

Its good to be able to get request log files, which in AWS terms means a CloudWatch Log Group. In out case that needs to be attached to the API Stage. Incidentally HTTP APIs can be configured to “auto-deploy”, i.e. you don’t have to tell it to update the stage, just updating the API routes is enough.

resource "aws_cloudwatch_log_group" "api_logs" {
  name = "/api/logs"
  retention_in_days = 30
}

resource "aws_apigatewayv2_stage" "stage" {
  api_id      = aws_apigatewayv2_api.api.id
  name        = "$default"
  auto_deploy = true
  access_log_settings {
    destination_arn = aws_cloudwatch_log_group.api_logs.arn
    format = jsonencode(
      {
        httpMethod     = "$context.httpMethod"
        ip             = "$context.identity.sourceIp"
        protocol       = "$context.protocol"
        requestId      = "$context.requestId"
        requestTime    = "$context.requestTime"
        responseLength = "$context.responseLength"
        routeKey       = "$context.routeKey"
        status         = "$context.status"
      }
    )
  }

  lifecycle {
    ignore_changes = [
      deployment_id,
      default_route_settings
    ]
  }
}

The lifecycle block at the bottom is necessary at the time of writing due to some slight oddities between the way HTTP APIs work and the way Terraform works. Basically it tells Terraform to ignore the deployment_id and default_route_settings fields if AWS says they don’t match whats in the state file.

The access_log_settings.format field specifies the log format. You can find more about that in the AWS Docs

There are some other resources available at this level to allow you to add a domain name and map this stage on to it, and to create a JWT Authorizer config, but for the sake of simplicity I will explore those in a separate article.

Adding a Route

Adding Routes in V1 of API Gateway required a lot of work. Multiple path segments, a resource to handle the incoming request, one for the Integration, another for the integration response and multiple resources for potential return codes. There is a lot to love about all that including VTL templates doing a lot of work for you, and being able to avoid calling Lambda Functions altogether, but it really was an awful lot of resources, and Terraform is very verbose. There were even some timing issues with AWS eventual consistency that made deployment of infra code changes quite painful.

HTTP APIs have done away with all of that in favour of a single resource for the route, and another one for the integration of the lambda function (with the option to add the JWT Authorizer)

resource "aws_apigatewayv2_route" "route" {
  api_id             = aws_apigatewayv2_api.api.id
  route_key          = "GET /{proxy+}"
  target             = "integrations/${aws_apigatewayv2_integration.integration.id}"
}

resource "aws_apigatewayv2_integration" "integration" {
  api_id           = aws_apigatewayv2_api.api.id
  integration_type = "AWS_PROXY"

  connection_type      = "INTERNET"
  description          = "This is our {proxy+} integration"
  integration_method   = "POST"
  integration_uri      = aws_lambda_function.lambda.invoke_arn
  passthrough_behavior = "WHEN_NO_MATCH"

  lifecycle {
    ignore_changes = [
      passthrough_behavior
    ]
  }
}

The fields are fairly self-explanatory, the aws_apigatewayv2_route attached a specific route to an API, and directs it to an integration.

The aws_apigatewayv2_integration tells API Gateway to forward the request to a Lambda function as an AWS_PROXY integration type, using a POST request to the Lambda service.

At some point there will be more integrations (you can already pass through to another API via a VPC link for example) however Lambda is the only service currently integrated with HTTP APIs.

Again, due to time of writing oddities in Terraform we need to add a lifecycle block to ignore the passthrough_behaviour field if it changes in AWS.

The Lambda Function Handler

The Lambda Function is as simple as providing the ARN (shown above). Given this is a {proxy+} route it could capture multiple route requests, and in our case we will handle just the / route and 404’s for anything not defined:

module.exports.handler = async (event) => {
  console.log('Event: ', event)

  if (event.path === '/') {
    return {
      statusCode: 200,
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        message: 'Welcome',
      }),
    }
  } else {
    return {
      statusCode: 404,
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        message: 'Not Found',
      }),
    }
  }
}

You can drop this code in to the resources created in the article Deploying Lambda Functions with Terraform and drop it straight in here.

Accessing the API

We’ve not added a domain name to map the API to, but we can still use the default url provided by API Gateway. The easiest way to get this is by adding an output to the code to tell Terraform to tell us what URL has been assigned to it:

output "api_url" {
  value = aws_apigatewayv2_stage.stage.invoke_url
}

You should now be able to curl the api_url that Terraform gives you and see that for a GET request on the / route you see a welcome response, a 404 response on all other requests, and an OPTIONS request should return the CORS response to allow browsers to access the API!


1097 Words

2020-06-19 07:56 +0000