20 January, 2021

Creating a GraphQL API Using AWS AppSync and DynamoDB with a bit of single table design

I did this as a little proof of concept for my client and thought I'll just map out my learning journey as a blog post.

What will we do

At my current client we are building a system that shows orders that Customer Service can work in. The feature request is now for the Customer Service agents to be able to put comments tied to the order they are working on in the system.

So we will design a solution for events being connected to an order and one such event can be a comment.

Oh yeah, will use ASW SAM CloudFormation to do this. So we need to install the aws cli and aws sam.

Architecture

We are working in the AWS Serverless stack and will keep to that. So the API will be a AppSync GraphQL API and will use DynamoDB to store the comments.

Architecuter diagram - browser to AppSync to dynamodb

I've lately been diving into DynamoDB single table design so I'll try to apply some of that here. As an experiment I will also try not to use lambda but instead to it with DynamoDB resolvers and VTL.

Database Design

As I eluded to above, we're going to try som single table design here. Which is overkill for this slim use case but since I know we are going to have other events connected to an order (refunds, returns, ... ). YAGNI... I know. But in this case I think it's a good design decision since I know that other stuff is coming down the pipe and the somewhat un-flexible nature of DynamoDB. Let's start with a partition key and let's name it pk and we'll also add a sort key named sk.

Should look something like this:

showing database design with sample data

Creating an AppSync API

Let's start by creating a folder.

mkdir order-comments-poc && cd order-comments-poc
touch template.yml

To get the template started let's add some initial stuff and under Resources we'll add the AppSync API.

AWSTemplateFormatVersion: 2010-09-09
Description: >-
  Order Comments PoC

Transform:
  - AWS::Serverless-2016-10-31

Resources:
  CommentsGraphQlApi:
    Type: AWS::AppSync::GraphQLApi
    Properties:
      AuthenticationType: API_KEY
      Name: !Sub ${AWS::StackName}

As you can see in the AuthenticationType property I chosen to go with API key security for the PoC, mainly for simplicity. Which means we have to add an API key as well:

ApiKey:
  Type: AWS::AppSync::ApiKey
  Properties:
    ApiId: !GetAtt CommentsGraphQlApi.ApiId
    Expires: 1630364400

Cool, we're getting somewhere.

Creating the DynamoDB table

We'll need a table to store stuff. As I mentioned earlier primary key will be pk and sort key will be sk and we'll also add a Time To Live (TTL) field named expires. This is due to GDPR and stuff so we don't keep data laying around longer than we need it.

So let's add this to our template.yml file.

# DynamoDb Table for storing events (comments are an event)
OrderEventsDynamoTable:
  Type: 'AWS::DynamoDB::Table'
  Properties:
    TableName: !Sub ${AWS::StackName}-events
    BillingMode: PAY_PER_REQUEST

    AttributeDefinitions:
      - AttributeName: 'pk'
        AttributeType: S
      - AttributeName: 'sk'
        AttributeType: S

    KeySchema:
      - AttributeName: 'pk'
        KeyType: 'HASH'
      - AttributeName: 'sk'
        KeyType: 'RANGE'

    TimeToLiveSpecification:
      AttributeName: expires
      Enabled: true

Permissions

For our AppSync API to be able to access the table I opted for a "CRUD" role.

CommentsAppSyncServiceRole:
  Type: AWS::IAM::Role
  Properties:
    AssumeRolePolicyDocument:
      Version: 2012-10-17
      Statement:
        - Effect: Allow
          Action:
            - sts:AssumeRole
          Principal:
            Service:
              - appsync.amazonaws.com
    ManagedPolicyArns:
      - arn:aws:iam::aws:policy/service-role/AWSAppSyncPushToCloudWatchLogs
    Policies:
      - PolicyName: DynamoDbCrudAccess
        PolicyDocument:
          Version: 2012-10-17
          Statement:
            - Effect: Allow
              Action:
                - dynamodb:GetItem
                - dynamodb:DeleteItem
                - dynamodb:PutItem
                - dynamodb:Scan
                - dynamodb:Query
                - dynamodb:UpdateItem
                - dynamodb:BatchWriteItem
                - dynamodb:BatchGetItem
                - dynamodb:DescribeTable
                - dynamodb:ConditionCheckItem
              Resource: !GetAtt OrderEventsDynamoTable.Arn

Let's test deploy it. If you haven't deployed before, try the guided option. I have and have saved my settings in samconfig.toml file.

> sam build

Build Succeeded

Built Artifacts  : .aws-sam/build
Built Template   : .aws-sam/build/template.yaml

Commands you can use next
=========================
[*] Invoke Function: sam local invoke
[*] Deploy: sam deploy --guided

> sam deploy --profile my-profile

... omitted for readability ...

Logging into your AWS Console you should now have an AppSync API and a DynamoDB table.

Schema

GraphQL is a typed language so we need to define our schema. We'll do that in a couple of steps.

First create a schema.graphql file (you can inline this in the template file but I really like to separate them for clarity and to get som nice VS syntax highlighting on my schema). In the schema file, let's add a type and a mutation so we can get data into the DynamoDB table:

type Comment {
  orderNumber: String!
  author: String!
  comment: String!
  created: Int! # date
  uniqueId: String!
}

type Mutation {
  addComment(orderNumber: String!, author: String!, comment: String!): Comment
}

schema {
  mutation: Mutation
}

Now we need to hook the schema up in the template.yml file.

Schema:
  Type: 'AWS::AppSync::GraphQLSchema'
  Properties:
    ApiId: !GetAtt CommentsGraphQlApi.ApiId
    DefinitionS3Location: schema.graphql

And we're off to the races :).

Add a comment

Now we have the pieces, so let's hook everything up for the addComment Mutation. We need to set up:

  • A datasource
  • A resolver
# DynoamoDB Data source
CommentsDynamoDBTableDataSource:
  Type: 'AWS::AppSync::DataSource'
  Properties:
    ApiId: !GetAtt CommentsGraphQlApi.ApiId
    Name: CommentsDynamoDBTable
    Description: Datasorurce for table containg events and comments
    Type: AMAZON_DYNAMODB
    ServiceRoleArn: !GetAtt CommentsAppSyncServiceRole.Arn
    DynamoDBConfig:
      AwsRegion: 'eu-west-1'
      TableName: !Ref OrderEventsDynamoTable

# Add comment resolver
MutaionAddCommentsResolver:
  Type: 'AWS::AppSync::Resolver'
  DependsOn: Schema
  Properties:
    ApiId: !GetAtt CommentsGraphQlApi.ApiId
    TypeName: Mutation
    FieldName: addComment
    DataSourceName: !GetAtt CommentsDynamoDBTableDataSource.Name
    RequestMappingTemplate: |
      #set( $d = $util.dynamodb )
      #set( $values = $d.toMapValues($context.arguments) )
      #set( $now = $util.time.nowEpochSeconds() )
      #set( $expires = $now + 70956000 )
      $!{values.put("created", $d.toDynamoDB($now))}
      $!{values.put("expires", $d.toDynamoDB($expires))}
      $!{values.put("uniqueId", $d.toDynamoDB($util.autoId()))}
      {
        "version" : "2017-02-28",
        "operation" : "PutItem",
        "key": {
          "pk": {"S": "ORDER#${context.arguments.orderNumber}"},
          "sk": {"S": "COMMENT#${now}"},
        },
        "attributeValues": $util.toJson($values),
      }
    ResponseMappingTemplate: |
      $utils.toJson($context.result)

Some explaining for the resolver. The RequestMappingTemplate is written in VTL (Velocity Templating Language). I save current time in a variable $nowand calculate the expiry time $expires. Calling DynamoDBs PutItem to insert the item.

      #set( $d = $util.dynamodb )
      #set( $values = $d.toMapValues($context.arguments) )
      #set( $now = $util.time.nowEpochSeconds() )
      #set( $expires = $now + 70956000 )

Then we add some stuff to the $values map:

      $!{values.put("created", $d.toDynamoDB($now))} 
      $!{values.put("expires", $d.toDynamoDB($expires))}
      $!{values.put("uniqueId", $d.toDynamoDB($util.autoId()))}

Do the sam build && sam deploy dance to deploy it.

Executing the mutation using the Queries option in the AWS Console for AppSync. Image showing the GraphQL call and result

Looking in DynamoDB it has landed there two. Image showing the DynamoDB item

Sweet!

List and delete comments

To finish up we will add GraphQL Query and Mutation for listing comments for an order and deleting a comment.

type Comment {
  orderNumber: String!
  author: String!
  comment: String!
  created: Int! # date
  uniqueId: String!
}

type PaginatedComments {
  comments: [Comment!]!
  nextToken: String
}

type Query {
  allCommentsByOrder(
    orderNumber: String!
    count: Int
    nextToken: String
  ): PaginatedComments!
}

type Mutation {
  addComment(orderNumber: String!, author: String!, comment: String!): Comment
  deleteComment(
    orderNumber: String!
    author: String!
    created: Int!
    uniqueId: String!
  ): Comment
}

schema {
  query: Query
  mutation: Mutation
}

And the resolvers for it:

QueryGetCommentsResolver:
  Type: 'AWS::AppSync::Resolver'
  DependsOn: Schema
  Properties:
    ApiId: !GetAtt CommentsGraphQlApi.ApiId
    TypeName: Query
    FieldName: allCommentsByOrder
    DataSourceName: !GetAtt CommentsDynamoDBTableDataSource.Name
    RequestMappingTemplate: |
      {
        "version" : "2017-02-28",
        "operation" : "Query",
        "query" : {
            "expression" : "#pk = :orderNumber AND begins_with(#sk, :commentPrefix)",
            "expressionNames" : {
                "#pk": "pk",
                "#sk": "sk"
            },
            "expressionValues" : {
                ":orderNumber" : {"S": "ORDER#${context.arguments.orderNumber}"},
                ":commentPrefix": {"S": "COMMENT#"}
            }
        },
        "limit" : 10,
        "scanIndexForward" : false,
        "consistentRead" : false,
      }
    ResponseMappingTemplate: |
      {
        "comments": $utils.toJson($context.result.items),
        #if( ${context.result.nextToken} )
          "nextToken": "${context.result.nextToken}",
        #end
      }

MutaionDeleteCommentsResolver:
  Type: 'AWS::AppSync::Resolver'
  DependsOn: Schema
  Properties:
    ApiId: !GetAtt CommentsGraphQlApi.ApiId
    TypeName: Mutation
    FieldName: deleteComment
    DataSourceName: !GetAtt CommentsDynamoDBTableDataSource.Name
    RequestMappingTemplate: |
      {
        "version" : "2017-02-28",
        "operation" : "DeleteItem",
        "key": {
          "pk": {"S": "ORDER#${context.arguments.orderNumber}"},
          "sk": {"S": "COMMENT#${ctx.args.created}"}
        },
        "condition" : {
          "expression": "uniqueId = :uniqueId",
          "expressionValues" : {
              ":uniqueId" : $util.dynamodb.toDynamoDBJson($ctx.args.uniqueId)
          }
        }
      }
    ResponseMappingTemplate: |
      $utils.toJson($context.result)

Tags: ,