ivanch.me/content/posts/api-gateway-terraform.md

8.6 KiB
Executable File

title date draft summary
AWS API Gateway with Terraform 2022-12-01T15:30:00-03:00 false Creating API Gateway endpoints with Terraform.

Right when we first started to use the AWS API Gateway, one of the things that did bother us was the fact that we had to manage lot of resources spread into 1,000s of lines of a couple of Terraform files, and it was a lot of work that required attention and time, things that are critical in software development as we all know.

So we decided to create a module to help us with this. Big thanks to Stephano who helped me a lot!

Before

Basically, when we are developing a new API, we need to create a 3 resources in the API Gateway. We need to create a new gateway_resource, a new gateway_method and a new gateway_integration, and then connect all of them using their respectives IDs.

Let's suppose a endpoint called /users/all. This is a snippet of the code we had before:

Resource

resource "aws_api_gateway_resource" "api_users_all" {
  rest_api_id = aws_api_gateway_rest_api.api.id
  parent_id   = aws_api_gateway_resource.api_users.id
  path_part   = "all"
}

Method

resource "aws_api_gateway_method" "api_users_all" {
  rest_api_id   = aws_api_gateway_rest_api.api.id
  resource_id   = aws_api_gateway_resource.users_all.id
  http_method   = "GET"

  request_parameters = {
    "method.request.header.Authorization" = true
  }
}

Integration

resource "aws_api_gateway_integration" "api_users_all" {
  rest_api_id             = aws_api_gateway_rest_api.api.id
  resource_id             = aws_api_gateway_resource.api_users_all.id
  http_method             = aws_api_gateway_method.api_users_all.http_method
  type                    = "HTTP_PROXY"
  integration_http_method = "GET"
  uri                     = "https://api.example.com/users/all"

  request_parameters = {
    "integration.request.header.Authorization" = true
  }
}

Obviously there is more code to that, but this is the main part of it and we will be focusing on that.

Creating a module

Now we can create a module to help us. We can start by creating a separate folder which will be our module, let's call it terraform/modules/api, inside of it there will be a couple of files:

variables.tf

Here we will define the variables that we will use in the module, what will come from the outside. Note that here it's just the essencial, you will add more things as you need.

# This is the parent resource ID in case we have something like /users/all/prune
variable "parent_id" {
  description = "Resource Parent ID"
  type        = string
}

# This is the last part of the path, we can infer it from the endpoint URI
variable "path_part" {
  description = "Path Part"
  type        = string
}

# Here we will put all the HTTP methods that the endpoint will accept
variable "http_methods" {
  description = "HTTP Methods"
  type        = list(string)
  default     = []
}

# The complete endpoint URI
variable "uri" {
  description = "URI"
  type        = string
  default     = ""
}

# The API Gateway ID
variable "gateway_id" {
  description = "API Gateway ID"
  type        = string
}

# If we have a URI that won't accept any HTTP method, we set this to true
variable "only_resource" {
  description = "Only create the resource"
  type        = bool
  default     = false
}

# Authorization as an example so that we can pass the headers
variable "authorization" {
  description = "Required authorization"
  type        = bool
  default     = false
}

outputs.tf

This file is needed for at least one important variable, which is the resource_id. That's needed if we have some endpoint like /users/all/prune which needs a parent_id.

output "resource_id" {
  value = local.resource_id
}

locals.tf

As we referenced the resource_id in the outputs.tf, we need to define it in the locals.tf.

locals {
  // this join is because we simply can't do aws_api_gateway_resource.api_resource.id
  resource_id = join("", aws_api_gateway_resource.api_resource[*].id)

  // if starts with '{' and ends with '}' then it's a path variable
  // take all the middle characters
  // if it's empty then it's a normal path
  path_variable = length(regexall("{.*}", var.path_part)) > 0 ? substr(var.path_part, 1, length(var.path_part) - 2) : ""

  // in case we need Authorization
  integration_request_parameters = var.authorization ? {
    "integration.request.header.Authorization" = "method.request.header.Authorization"
  } : {}

  method_request_parameters = {
    "method.request.header.Authorization" = var.authorization
  }
}

gateway.resources.tf

Here is where the fun begins, thank God it's pretty straightforward. All of the variables are coming from the variables.tf file.

resource "aws_api_gateway_resource" "api_resource" {
  rest_api_id = var.gateway_id
  parent_id   = var.parent_id
  path_part   = var.path_part
}

gateway.methods.tf

Since we need one aws_api_gateway_method for each HTTP Method, we use the count to iterate over the list of HTTP Methods and create one api_gateway_method for each http method we defined in the var.http_methods list.

resource "aws_api_gateway_method" "api_method" {
  count        = var.only_resource ? 0 : length(var.http_methods)
  rest_api_id  = var.gateway_id
  resource_id  = local.resource_id

  http_method   = var.http_methods[count.index]
  authorization = var.authorization ? "CUSTOM" : "NONE"

  // Got a path variable? No problem! We deal with that too right here
  request_parameters = merge(local.method_request_parameters, local.path_variable != "" ? {
    "method.request.path.${local.path_variable}" = local.path_variable != ""
  } : {})
}

gateway.integrations.tf

The same idea goes for api_gateway_integration.

resource "aws_api_gateway_integration" "api_integration" {
  count       = var.only_resource ? 0 : length(var.http_methods)
  rest_api_id = var.gateway_id
  resource_id = local.resource_id
  http_method = aws_api_gateway_method.api_method[count.index].http_method

  integration_http_method = var.http_methods[count.index]
  uri                     = var.uri

  // Aahh I see your path variable, let's do some magic here
  request_parameters = merge(local.integration_request_parameters, local.path_variable != "" ? {
    "integration.request.path.${local.path_variable}" = "method.request.path.${local.path_variable}"
  } : {})
}

Using the module

Now that we have the module, we can use it in our main.tf file. We will use the same example as before, but now we will use the module and we will create some other endpoints as example as well.

# this is our main API endpoint, we don't want to receive any request here, so we will only create the resource
# /users (only resource)
module "api_users" {
  source    = "./api"

  gateway_id    = gateway.outputs.gateway.gateway_config.gateway_id
  parent_id     = gateway.outputs.gateway.gateway_config.root_endpoints.api_root
  path_part     = "users"
  only_resource = true
}

# /users/all (get)
module "api_users_all" {
  source    = "./api"

  gateway_id    = gateway.outputs.gateway.gateway_config.gateway_id
  parent_id     = module.api_users.resource_id
  path_part     = "all"
  http_methods  = ["GET"]
  uri           = "http://api.example.com/users/all"
}

# /users/all/{userid} (get, post, put, delete)
module "api_users_all" {
  source    = "./api"

  gateway_id    = gateway.outputs.gateway.gateway_config.gateway_id
  parent_id     = module.api_users_all.resource_id
  path_part     = "{userid}"
  http_methods  = ["GET", "POST", "PUT", "DELETE"]
  uri           = "http://api.example.com/users/all/{userid}"
}

# and so on...

Conclusion

For one endpoint, we went from having to manage 15 lines splitted in 3 files to just 5 lines inside of one file. If you have to manage hundreds of endpoints, that will be a great help.

WWW-Authenticate header

We can also add the WWW-Authenticate header to the request for example. We tried to do that by adding it to the files properly, but it didn't work. The reason was that the API Gateway was not passing the WWW-Authenticate to our API, and that's because of the name of the header. You can call it WWW-Authenticate-Header for example and it will work.

Note

This code has not been tested "as is", but it has been tested as part of a bigger project. There is always room for improvements and more possibilities depending on the context, but it's a good start.

There has been a lot of pieces of Terraform code that was omitted, like when we use the declare the terraform_remote_state or the authorizer_id which you will need if using authorization "CUSTOM".