Search code examples
amazon-web-servicesterraformaws-api-gateway

Terraform: Dynamically generate REST API endpoints in AWS API Gateway based on user input


I am building a Terraform module that deploys a REST API in AWS API Gateway. The users of this module will provide input like this:

api_resources = {
    resource1 = {
        api_endpoint = "/pets/{petID}"
        http_method = "GET"
    },
    resource2 = {
        api_endpoint = "/pets"
        http_method = "GET"
    },
    resource3 = {
        api_endpoint = "/toys"
        http_method = "GET"
    },
    resource4 = {
        api_endpoint = "/pets"
        http_method = "POST"
    }
}


In my module, this input will be deployed using the aws_api_gateway_resource Terraform resource. It takes the following arguments:

resource "aws_api_gateway_resource" "resource" {
  rest_api_id = # ID of the parent REST API resource.
  parent_id   = # ID of the immediate parent of this "part" of the API endpoint.
  path_part   = # The rightmost "part" of the endpoint URL.
}

Official documentation: Link.


Example: For the input /pets/{petID}, the path_part above will be {petID} & the parent_id will be the ID of the Terraform resource that created the pets path_part.

So something like this:

resource "aws_api_gateway_resource" "pets_resource" {
  rest_api_id = aws_api_gateway_rest_api.rest_api.id
  parent_id   = aws_api_gateway_rest_api.rest_api.root_resource_id
  path_part   = "pets"
}

resource "aws_api_gateway_resource" "petID_resource" {
  rest_api_id = aws_api_gateway_rest_api.rest_api.id
  parent_id   = aws_api_gateway_resource.pets_resource.id
  path_part   = "{petID}"
}

Note: The aws_api_gateway_rest_api already exists elsewhere:

resource "aws_api_gateway_rest_api" "rest_api" {
  name = "my-api"
}

In order to do all this dynamically based on user input, I have:

  • Extracted all API endpoints from the input.
  • Looped over them and created one aws_api_gateway_resource resource for each.

Like this:

locals {
  api_endpoints = toset([
    for key, value in var.api_resources :
    trimprefix(value.api_endpoint, "/")
  ])
}

resource "aws_api_gateway_resource" "resource" {
  rest_api_id = aws_api_gateway_rest_api.rest_api.id
  parent_id   = aws_api_gateway_rest_api.rest_api.root_resource_id
  for_each    = local.api_endpoints # pets/{petID}, pets, toys
  path_part   = each.key
}

This works well for top-level resources /pets & /toys as seen in this Terraform plan:

Terraform will perform the following actions:

  # aws_api_gateway_resource.resource["pets"] will be created
  + resource "aws_api_gateway_resource" "resource" {
      + id          = (known after apply)
      + parent_id   = "e79wlf30x5"
      + path        = (known after apply)
      + path_part   = "pets"
      + rest_api_id = "yrpm6dx4z8"
    }

  # aws_api_gateway_resource.resource["pets/{petID}"] will be created
  + resource "aws_api_gateway_resource" "resource" {
      + id          = (known after apply)
      + parent_id   = "e79wlf30x5"
      + path        = (known after apply)
      + path_part   = "pets/{petID}"
      + rest_api_id = "yrpm6dx4z8"
    }

  # aws_api_gateway_resource.resource["toys"] will be created
  + resource "aws_api_gateway_resource" "resource" {
      + id          = (known after apply)
      + parent_id   = "e79wlf30x5"
      + path        = (known after apply)
      + path_part   = "toys"
      + rest_api_id = "yrpm6dx4z8"
    }

Plan: 3 to add, 0 to change, 0 to destroy.

How can I make it work for nested resources like /pets/{petID}? Creation of the /pets/{petID} resource in the above plan will fail! The challenge is setting the correct parent_id for aws_api_gateway_resource for nested resources. And this needs to work for any level of nesting.


Note: There exists a data source that can return the ID of any URL path like this:

data "aws_api_gateway_resource" "pets_resource" {
  rest_api_id = aws_api_gateway_rest_api.rest_api.id
  path        = "/pets"
}

I just don't know how to put it all together!


Solution

  • I ended up changing the input format to make things easier. The end result is as follows:

    User input:

    api_endpoints = {
        "/" = { get = "lambda1" }
        "/pets" = {
            get = "lambda2"
            post = "lambda1"
        }
        "/pets/{petID}" = { get = "lambda3" }
        "/toys" = { get = "lambda3" }
    }
    
    lambda_functions = {
        lambda1 = {
            runtime = "nodejs14.x"
            handler = "index.handler"
            zip = "../lambda1.zip"
        }
        lambda2 = {
            runtime = "nodejs14.x"
            handler = "index.handler"
            zip = "../lambda2.zip"
        }
        lambda3 = {
            runtime = "python3.7"
            handler = "index.handler"
            zip = "../lambda3.zip"
        }
    }
    

    And the code inside my Terraform module that works with this user input is as follows:

    The REST API:

     locals {
      openAPI_spec = {
        for endpoint, spec in var.api_endpoints : endpoint => {
          for method, lambda in spec : method => {
            x-amazon-apigateway-integration = {
              type       = "aws_proxy"
              httpMethod = "POST"
              uri        = "arn:aws:apigateway:${data.aws_region.region.name}:lambda:path/2015-03-31/functions/arn:aws:lambda:${data.aws_region.region.name}:${data.aws_caller_identity.identity.account_id}:function:${lambda}/invocations"
            }
          }
        }
      }
    }
    
    resource "aws_api_gateway_rest_api" "rest_api" {
      name = var.api_name
      endpoint_configuration {
        types = ["REGIONAL"]
      }
      body = jsonencode({
        openapi = "3.0.1"
        paths   = local.openAPI_spec
      })
    }
    

    The Lambda functions:

     module "lambda_function" {
      source                                  = "terraform-aws-modules/lambda/aws"
      for_each                                = var.lambda_functions
      function_name                           = each.key
      runtime                                 = each.value.runtime
      handler                                 = each.value.handler
      create_package                          = false
      local_existing_package                  = each.value.zip
      create_current_version_allowed_triggers = false
      allowed_triggers = {
        api-gateway = {
          service    = "apigateway"
          source_arn = "${aws_api_gateway_rest_api.rest_api.execution_arn}/*/*/*"
        }
      }
    }
    

    More details in my GitHub repo.