DEV Community

Luis Valdés
Luis Valdés

Posted on • Originally published at valdes.com.br on

Create an AppSync API using Terraform

In this article we are going to dive into the details of creating a graphql api using AWS AppSync, the infrastructure is written using terraform.

The graphql api needs to provide two functionalities, a query to get tasks and a mutation to add tasks, the api needs to be authenticated and the data stored.

In the AppSync context we need to create two resolvers, one for getTasks and one for addTasks

Requirements

  • git
  • NodeJS 14 or later, my version is v18.18.0
  • An AWS account and configured credentials
  • An S3 bucket for the terraform backend
  • Install terraform cli

TL;DR;

Clone the repo and follow the instructions to deploy the project, you can use the gitpod configuration which comes with nodejs, aws cli v2, docker and terraform installed

Architecture

Here is an overview of the architecture.

Now let’s jump into the details of the different services we defined in our architecture.

AWS Identity and Access Management (IAM) is a web service that helps you securely control access to AWS resources. With IAM, you can centrally manage permissions that control which AWS resources users can access. You use IAM to control who is authenticated (signed in) and authorized (has permissions) to use resources.

Amazon Cognito delivers frictionless customer identity and access management (CIAM) with a cost-effective and customizable platform that allows you to implement secure, frictionless customer identity and access management that scales.

Amazon DynamoDB is a serverless, NoSQL, fully managed database service with single-digit millisecond response times at any scale, enabling you to develop and run modern applications while only paying for what you use.

AWS Lambda runs code without provisioning or managing servers, creating workload-aware cluster scaling logic, maintaining event integrations, or managing runtimes.

AWS AppSync simplifies application development with GraphQL APIs by providing a single endpoint to securely query or update data from multiple databases, microservices, and APIs.

Creating the tasks table

To store the data generated by the users of the api, we are going to use DynamoDB, here we are creating the tasks table with a key named id and an attribute named owner, we are also creating a global secondary index to be able to retrieve records by owner

resource "aws_dynamodb_table" "tasks_table" {
  name = "TasksTable"
  billing_mode = "PAY_PER_REQUEST"
  hash_key = "id"

  attribute {
    name = "id"
    type = "S"
  }

  attribute {
    name = "owner"
    type = "S"
  }

  global_secondary_index {
    name = "byOwner"
    hash_key = "owner"
    range_key = "id"
    projection_type = "ALL"
  }

}
Enter fullscreen mode Exit fullscreen mode

Create a Cognito User Pool and User Pool Client

Since we are focusing on learning concepts, our cognito user pool configuration is not for production, for a production deployment you will need to modify the password policy

resource "aws_cognito_user_pool" "user_pool" {
  name = "UserPool"
  username_attributes = ["email"]
  auto_verified_attributes = ["email"]
  password_policy {
    minimum_length = 8
    require_lowercase = false
    require_uppercase = false
    require_numbers = false
    require_symbols = false
  }
  admin_create_user_config {
    allow_admin_create_user_only = false # enable self sign in
  }
}

resource "aws_cognito_user_pool_client" "user_pool_client" {
  name = "UserPoolClient"
  user_pool_id = aws_cognito_user_pool.user_pool.id
}
Enter fullscreen mode Exit fullscreen mode

Creating the AppSync API

To create the graphql api resource we need a schema file, define a visibility, in our scenario is GLOBAL, then we need to set our authentication configuration, we are using cognito user pools and we provide the configuration for the user pool

resource "aws_appsync_graphql_api" "graphql_api" {
  name = "terraform-todos-api"

  schema = file("schema.graphql")

  visibility = "GLOBAL"

  authentication_type = "AMAZON_COGNITO_USER_POOLS"

  user_pool_config {
    default_action = "ALLOW"
    user_pool_id = aws_cognito_user_pool.user_pool.id
    app_id_client_regex = aws_cognito_user_pool_client.user_pool_client.id
  }

}
Enter fullscreen mode Exit fullscreen mode

I the file schema.graphql, we define the type Task, the query getTasks and the mutation addTask, TaskConnection is a type we are using to iterate the result from getTasks

type Task {
  id: ID!
  text: String!
  owner: ID!
  createdAt: String!
}

type TaskConnection {
 items: [Task!]
 nextToken: String
}

input TaskInput {
  text: String!
}

type Query {
  getTasks(limit: Int, nextToken: String): TaskConnection!
}

type Mutation {
  addTask(input: TaskInput!): Task!
}
Enter fullscreen mode Exit fullscreen mode

Create a policy that adds write access to Tasks table

The lambda function running the code for addTask needs the dynamodb::PutItem permission on the Tasks table, to be able to do that we are creating a policy with the permissions we need

data "aws_iam_policy_document" "lambda_put_item_policy" {
  statement {
    actions = ["dynamodb:PutItem"]
    resources = [
      aws_dynamodb_table.tasks_table.arn
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Create an IAM role for the lambda function

The IAM role for the lambda function needs some permissions for execution, the assume role policy allows lambda to assume this role, the IAM role also have a managed policy AWSLambdaBasicExecutionRole that provides the lambda function access to create logs. To finish the configuration of the role we add an inline policy, we load the policy document and provide a name for it

resource "aws_iam_role" "lambda_execution_role" {
  name = "lambdaExecutionRole"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        }
      },
    ]
  })
  managed_policy_arns = ["arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"]

  inline_policy {
    name = "lambda_put_item_inline"
    policy = data.aws_iam_policy_document.lambda_put_item_policy.json
  }
}
Enter fullscreen mode Exit fullscreen mode

Create the addTask lambda function

We are providing a name and a filename, the filename is used to load a packaged version of our function as a zip file.

We are using the IAM role described previously, our function will be able create a record on the Tasks table, we define the handler and the runtime of our function.

To finish our lambda function we add an environment variable with the name of the Tasks table

resource "aws_lambda_function" "add_task_lambda_function" {
  function_name = "addTaskLambdaFunction"
  filename = "add_task_lambda_function.zip"
  role = aws_iam_role.lambda_execution_role.arn
  handler = "index.addTask"
  runtime = "nodejs20.x"
  timeout = 30
  environment {
    variables = {
      TASKS_TABLE = aws_dynamodb_table.tasks_table.name
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The source code of the lambda function for addTask uses DynamoDB to store a new item, the id of the item is generated using the ulid library, ULID stands for U niversally unique L exicographically sortable ID entifiers, the values are sortable even when generated within a millisecond, this is an example

ulid() // 01HR8FDWSNDC63YF1HBHCB1QBF
Enter fullscreen mode Exit fullscreen mode

The field id is an ulid generated value, and it is sortable.

The value of field owner will have the username field of the cognito identity parameter, and our last parameter is the text that comes from the arguments.

The typescript implementation needs the type InputArguments with the structure needed to be used by AppSyncResolverEvent , by configuring this the application is able to call event.arguments.input.text to obtain the text input of an invocation

import { AppSyncResolverEvent , AppSyncIdentityCognito} from "aws-lambda";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { PutCommand, DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { ulid } from 'ulid'

const client = new DynamoDBClient({});
const documentClient = DynamoDBDocumentClient.from(client);

type InputArguments = {
  input: {
    text: String
  }
}

export const addTask = async (event: AppSyncResolverEvent<InputArguments> ) => {

  const id = ulid()
  const createdAt = new Date().toJSON()
  const identity = event.identity as AppSyncIdentityCognito

  const data = {
    id,
    owner: identity.username,
    text: event.arguments.input.text,
    createdAt
  }
  const command = new PutCommand({
    TableName: process.env.TASKS_TABLE,
    Item: data
  });

  await documentClient.send(command)

  return data
}
Enter fullscreen mode Exit fullscreen mode

Configuring the IAM role for the AppSync data sources

The data source needs to have Query permissions on the global secondary index called byOwner

data "aws_iam_policy_document" "appsync_query_index_inline_policy" {
  statement {
    actions = ["dynamodb:Query"]
    resources = [
      "${aws_dynamodb_table.tasks_table.arn}/index/byOwner"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

For the sake of keeping this deployment simple, we are going to use the same IAM role to call our add task lambda function, in order to do it we need to add InvokeFunction permission

data "aws_iam_policy_document" "appsync_invoke_lambda_inline_policy" {
  statement {
    actions = ["lambda:InvokeFunction"]
    resources = [
      aws_lambda_function.add_task_lambda_function.arn
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we have two policies, one to query the index byOwner and another to invoke the lambda function for addTask, we are going to use these policies in our role for appsync data sources

Creating the IAM role for the appsync data sources

The role needs to be assumed by the appsync service and as we explained previously, we need our role to be able to query an index, and invoke a lambda function, we add two inline policies, use the query index and invoke lambda policies we created before

resource "aws_iam_role" "appsync_datasource_role" {
  name = "appsync_datasource_role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "appsync.amazonaws.com"
        }
      },
    ]
  })
  inline_policy {
    name = "appsync_query_index_inline"
    policy = data.aws_iam_policy_document.appsync_query_index_inline_policy.json
  }
  inline_policy {
    name = "appsync_invoke_lambda_inline"
    policy = data.aws_iam_policy_document.appsync_invoke_lambda_inline_policy.json
  }
}
Enter fullscreen mode Exit fullscreen mode

Creating the tasks table data source

We create the tasks table datasource using our graphql api, in this scenario it is of type AMAZON_DYNAMODB, so we are going to use a table as data source, we provide the dynamodb_config configuration with the name of the table from our previously created Tasks table and the region

resource "aws_appsync_datasource" "tasks_table_datasource" {
  api_id = aws_appsync_graphql_api.graphql_api.id
  name = "TasksTableDataSource"
  type = "AMAZON_DYNAMODB"
  service_role_arn = aws_iam_role.appsync_datasource_role.arn
  dynamodb_config {
    table_name = aws_dynamodb_table.tasks_table.name
    region = var.region
  }
}
Enter fullscreen mode Exit fullscreen mode

Creating the getTasks resolver using JavaScript

After creating our tasks data source, we are now ready to create our JavaScript resolver, we do this by creating an appsync resolver, using our graphql api, with type of Query and field equals to getTasks.

The runtime of the resolver is APPSYNC_JS, we provide a path to the code and a data source

resource "aws_appsync_resolver" "get_tasks_resolver" {
  api_id = aws_appsync_graphql_api.graphql_api.id
  type = "Query"
  field = "getTasks"
  runtime {
    name = "APPSYNC_JS"
    runtime_version = "1.0.0"
  }
  code = file("resolvers/getTasks.js")
  data_source = aws_appsync_datasource.tasks_table_datasource.name
}
Enter fullscreen mode Exit fullscreen mode

The resolver getTask.js exports two functions, the request function returns an operation Query on the index byOwner, ordered in ascending order by the value of the id field, the value for the owner variable is obtained from the current authenticated user making the request, the value is located in the context variable of the function

import { util } from '@aws-appsync/utils';

export function request(context) {
  return {
      operation: "Query",
      query: {
          expression: "#owner = :userId",
          expressionNames: {
            "#owner": "owner"
          },
          expressionValues: {
              ":userId": util.dynamodb.toDynamoDB(context.identity.username)
          }
      },
      index: "byOwner",
      nextToken: context.arguments.nextToken,
      limit: context.arguments.limit,
      scanIndexForward: true, // true order ASC, false order DESC
      consistentRead: false,
      select: "ALL_ATTRIBUTES"
  }
}

export function response(context) {
  return {
    items: context.result.items,
    nextToken: context.result.nextToken
  }
}
Enter fullscreen mode Exit fullscreen mode

The response function returns a structure of items and pagination.

Creating the data source for the addTask lambda function

We are close to the end of our application, we need to add the add task data source, which in this scenario is of type AWS_LAMBDA.

The service role for the data source is the appsync_datasource_role, which allows AppSync to invoke the lambda function add task

resource "aws_appsync_datasource" "add_task_datasource" {
  api_id = aws_appsync_graphql_api.graphql_api.id
  name = "AddTaskDataSource"
  type = "AWS_LAMBDA"
  service_role_arn = aws_iam_role.appsync_datasource_role.arn
  lambda_config {
    function_arn = aws_lambda_function.add_task_lambda_function.arn
  }
}
Enter fullscreen mode Exit fullscreen mode

The last part of the puzzle is to add the resolver for our mutation addTask

resource "aws_appsync_resolver" "add_task_resolver" {
  api_id = aws_appsync_graphql_api.graphql_api.id
  type = "Mutation"
  field = "addTask"
  data_source = aws_appsync_datasource.add_task_datasource.name
}
Enter fullscreen mode Exit fullscreen mode

Deploy the project

We need the name of the previously created bucket, to use it as parameter for the terraform init command

terraform init -backend-config="bucket=<YOUR_BACKEND_S3_BUCKET_NAME>"
Enter fullscreen mode Exit fullscreen mode

Then we need to run to see the details of the resources to te created

terraform plan
Enter fullscreen mode Exit fullscreen mode

To make changes and create the resources we run

terraform apply
Enter fullscreen mode Exit fullscreen mode

How to test the project

Open your AWS console, the first thing we need to is to create an user in our user pool, to do that we are going to the search bar and search for cognito

Click on the User Pool we created using CDK

Once in the details page of the user pool, click on create user, fill the user email and mark as verified, last fill a password for the user, you will need this credentials later on.

Now open the search bar and search for appsync

On the list, select the todos-api and once on the details page for the api, on the left bar, go to the menu Queries and click in Login with User Pools

Select the client ID that is available and login with the user created in the previous step.

We want to create a task, in the explorer select query and change for mutation then click on the + plus button, choose the addTask and click on the input text, in the example text is Hello World then click on Run MyMutation

When you run the mutation you will have a result like this one

Now go to the explorer and change the mutation to query and click on + plus, click on getTasks and click on all fields, then run MyQuery

Top comments (0)