Terraform + AWS AppSync + Apache Velocity Template Language (VTL)

Terraform + AWS AppSync + Apache Velocity Template Language (VTL)

Structuring your AppSync api resolvers when provisioning with Terraform

Preamble

I often find myself making provisions for scalability, maintainability, and readability for every project I work on, right from its infancy. Structuring the project files and folders is always an intrinsic part of that venture.

With Terraform as an IAC tool for defining an AppSync API, this is not an exception. Defining the GraphQL models, queries, and mutation signatures, which in essence is an undoubtedly important part of working with AppSync, and structuring those files in a readable and maintainable fashion while trying to fulfill the requirements of Terraform, would be the crux of this short essay.

This essay will, in no way, try to teach the underlying process of writing Terraform configurations. However, the main focus would be on how to define GraphQL models, queries, and mutations neatly while managing those separate files during provisioning with Terraform.

Mode of Approach

We would begin by defining a Terraform module for the AppSync API, which would contain the standard files. This will include, among others things, output.tf, main.tf, LICENCE, CHANGELOG.md, README.md, appsync.tf, variables.tf, and so on. We will also define an examples folder, where we will provide a simple example that utilizes the module in question. The folder structure will look like this:

The file iam.tf in the root contains the permissions necessary for the resources within the module to freely communicate with each other.

appsync.tf contains the actual definition of our API and will be our main focus going forward. We proceed by defining a locals as below:

locals {
  schemas_lists = [for file in fileset(var.schema_directory, "*.graphql"): 
    file(join("", [var.schema_directory, "/", file]))
  ]
  schemas = join("", local.schemas_lists)
  apiTypes = jsondecode(file("${path.cwd}/apiTypes.json"))
  resolvers = { for f in fileset(var.resolver_path, "*.vtl") : f => merge({
    type  = split("_", f)[0]
    field  = split("_", f)[1]

    request_template =  "${var.resolver_path}/${f}"
    response_template =  "${var.resolver_path}/${f}"

    datasources = local.apiTypes[split("_", f)[0]][split("_", f)[1]]
  }) if var.create_graphql_api }
}

We access the folder containing the GraphQL definitions by providing the name of the folder, and we then loop through those files and append them to a local variable schemas. We also provide the names of our mutations and queries in a JSON file. This is pretty straight forward.

The same is also done for the resolvers, using the variable resolver_path.

Our apiTypes.json would look not so different from the one shown below:

{
    "Query": {
        "getCM": "change_management_internal"
    },
    "Mutation": {
        "addCM": "change_management_internal"
    }
}

We can then go ahead and define the rest of the AppSync resource within the appsync.tf file as below:

# GraphQL API
resource "aws_appsync_graphql_api" "this" {
  count = var.create_graphql_api ? 1 : 0

  name                = var.api_name
  authentication_type = var.authentication_type
  schema = local.schemas

  ...


  tags = merge({ Name = var.api_name }, var.graphql_api_tags)
}

# Define datasource
# Define Resolvers
resource "aws_appsync_resolver" "this" {
  for_each = local.resolvers

  api_id = aws_appsync_graphql_api.this[0].id
  type   = each.value.type
  field  = each.value.field
  kind   = lookup(each.value, "kind", null)

  response_template = file(each.value.response_template)
  request_template  = file(each.value.request_template)

  data_source = lookup(each.value, "datasources", null)
  max_batch_size = lookup(each.value, "max_batch_size", null)
}

And we are basically done. We simply need to define the content of variables.tf and outputs.tf accordingly

To use the created module, we define folders that will contain the AppSync models as well as the resolvers within, say, the example folder. The naming convention is also very important, at least for the resolves. The names of the files should match those defined in apiTypes.json.

Result

We can then easily and flawlessly use the AppSync module as follows:

module "appsync" {
  source = "../"

  api_name = "sample-appsync-api"

  schema_directory = "./objects"
  resolver_path = "./resolvers"
  domain_name_association_enabled = true
  caching_enabled                 = true

  authentication_type = "AMAZON_COGNITO_USER_POOLS"
  user_pool_config = {
    aws_region     = "eu-central-1"
    default_action = "DENY"
    user_pool_id   = "eu-central-1_GAtMVeLwu"
  }

   ... # every other variable follows as should

  datasources = {}

   ...
}

This looks straightforward to manage. We can even take it a little step further and group the queries separate from mutations, as well as the models, in separate folders and then update the code accordingly. We can still do the same for the resolvers while still maintaining our initial goals of readability, maintainability, and scalability.

Conclusion

We tried to define our AppSync module in Terraform while maintaining the structure we love about AppSync. We looked at how we can define resolver templates, GraphQL models, queries, and mutations in separate folders without too much hassle. This has worked out quite well, so we can now go ahead without ending up with a code base we would come to hate.

I would appreciate any comments, questions, or remarks. I would also provide a sample working code repository should anyone need that for improved clarity.