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.
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.
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.
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.
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:
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.
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
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.
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 :).
Now we have the pieces, so let's hook everything up for the addComment Mutation. We need to set up:
# 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 $now
and 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.
Looking in DynamoDB it has landed there two.
Sweet!
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: AWS, Serverless