├── lib ├── mapping-templates │ ├── Mutation.createComment.response.vtl │ ├── Post.comments.response.vtl │ ├── Query.getPostsForSite.response.vtl │ ├── Mutation.createPost.response.vtl │ ├── Mutation.createSite.response.vtl │ ├── Post.comments.request.vtl │ ├── Query.getSite.request.vtl │ ├── Query.getSite.response.vtl │ ├── Query.getPostsForSite.request.vtl │ ├── Mutation.createSite.request.vtl │ ├── Mutation.createComment.request.vtl │ └── Mutation.createPost.request.vtl ├── schema.graphql └── single-table-cdk-stack.ts ├── .npmignore ├── .gitignore ├── jest.config.js ├── package.json ├── tsconfig.json ├── bin └── single-table-cdk.ts ├── cdk.json ├── index.html └── README.md /lib/mapping-templates/Mutation.createComment.response.vtl: -------------------------------------------------------------------------------- 1 | $utils.toJson($ctx.result) -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | -------------------------------------------------------------------------------- /lib/mapping-templates/Post.comments.response.vtl: -------------------------------------------------------------------------------- 1 | $util.toJson({ 2 | "cursor": $ctx.result.nextToken, 3 | "comments": $ctx.result.items 4 | }) -------------------------------------------------------------------------------- /lib/mapping-templates/Query.getPostsForSite.response.vtl: -------------------------------------------------------------------------------- 1 | $util.toJson({ 2 | "cursor": $ctx.result.nextToken, 3 | "posts": $ctx.result.items 4 | }) -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['/test'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /lib/mapping-templates/Mutation.createPost.response.vtl: -------------------------------------------------------------------------------- 1 | #if ( $ctx.error ) 2 | #if ( $ctx.error.type.equals("DynamoDB:ConditionalCheckFailedException") ) 3 | $util.error("Error creating Post.", "PostAlreadyExistsError") 4 | #else 5 | $util.error($ctx.error.message, $ctx.error.type) 6 | #end 7 | #end 8 | 9 | $util.toJson($ctx.result) -------------------------------------------------------------------------------- /lib/mapping-templates/Mutation.createSite.response.vtl: -------------------------------------------------------------------------------- 1 | #if ( $ctx.error ) 2 | #if ( $ctx.error.type.equals("DynamoDB:ConditionalCheckFailedException") ) 3 | $util.error("Site with this domain already exists. Please try another.", "SiteAlreadyExistsError") 4 | #else 5 | $util.error($ctx.error.message, $ctx.error.type) 6 | #end 7 | #end 8 | 9 | $util.toJson($ctx.result) -------------------------------------------------------------------------------- /lib/mapping-templates/Post.comments.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version" : "2018-05-29", 3 | "operation" : "Query", 4 | "query" : { 5 | "expression": "PK = :pk", 6 | "expressionValues" : { 7 | ":pk": $util.dynamodb.toDynamoDBJson("POST#$ctx.source.id"), 8 | } 9 | }, 10 | "scanIndexForward": false, 11 | "limit": $util.defaultIfNull(${ctx.args.num}, 20), 12 | "nextToken": $util.toJson($util.defaultIfNullOrBlank($ctx.args.after, null)) 13 | } -------------------------------------------------------------------------------- /lib/mapping-templates/Query.getSite.request.vtl: -------------------------------------------------------------------------------- 1 | #if($ctx.info.selectionSetList.contains("posts")) 2 | #set($limit = 11) 3 | #else 4 | #set($limit = 1) 5 | #end 6 | 7 | { 8 | "version": "2018-05-29", 9 | "operation": "Query", 10 | "query": { 11 | "expression": "PK = :pk", 12 | "expressionValues": { 13 | ":pk": $util.dynamodb.toDynamoDBJson("SITE#$ctx.args.domain") 14 | } 15 | }, 16 | "limit": $limit, 17 | "scanIndexForward": false 18 | } -------------------------------------------------------------------------------- /lib/mapping-templates/Query.getSite.response.vtl: -------------------------------------------------------------------------------- 1 | #if($ctx.results.items.size() == 0) 2 | #return 3 | #end 4 | 5 | #set($site = {}) 6 | #set($posts = []) 7 | #foreach($item in $ctx.result.items) 8 | #if($item["_TYPE"] == "SITE") 9 | #set ($site = $item) 10 | #elseif($item["_TYPE"] == "POST") 11 | $util.qr($posts.add($item)) 12 | #end 13 | #end 14 | 15 | $util.qr($site.put("posts", { 16 | "cursor": $ctx.result.nextToken, 17 | "posts": $posts 18 | })) 19 | 20 | $util.toJson($site) -------------------------------------------------------------------------------- /lib/mapping-templates/Query.getPostsForSite.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version" : "2018-05-29", 3 | "operation" : "Query", 4 | "query": { 5 | "expression": "PK = :pk AND SK < :sk", 6 | "expressionValues": { 7 | ":pk": $util.dynamodb.toDynamoDBJson("SITE#$ctx.args.domain"), 8 | ":sk": $util.dynamodb.toDynamoDBJson("SITE#$ctx.args.domain") 9 | } 10 | }, 11 | "scanIndexForward": false, 12 | "limit": $util.defaultIfNull(${ctx.args.num}, 20), 13 | "nextToken": $util.toJson($util.defaultIfNullOrBlank($ctx.args.after, null)) 14 | } -------------------------------------------------------------------------------- /lib/mapping-templates/Mutation.createSite.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version" : "2018-05-29", 3 | "operation" : "PutItem", 4 | "key" : { 5 | "PK": $util.dynamodb.toDynamoDBJson("SITE#$ctx.args.input.domain"), 6 | "SK": $util.dynamodb.toDynamoDBJson("SITE#$ctx.args.input.domain") 7 | }, 8 | "attributeValues" : { 9 | "_TYPE": $util.dynamodb.toDynamoDBJson("SITE"), 10 | "id": $util.dynamodb.toDynamoDBJson($util.autoId()), 11 | "domain": $util.dynamodb.toDynamoDBJson($ctx.args.input.domain), 12 | "name": $util.dynamodb.toDynamoDBJson($ctx.args.input.name), 13 | }, 14 | "condition": { 15 | "expression": "attribute_not_exists(PK)" 16 | } 17 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "single-table-cdk", 3 | "version": "0.1.0", 4 | "bin": { 5 | "single-table-cdk": "bin/single-table-cdk.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "cdk": "cdk" 12 | }, 13 | "devDependencies": { 14 | "@aws-cdk/aws-appsync-alpha": "^2.27.0-alpha.0", 15 | "@types/jest": "^27.5.0", 16 | "@types/node": "10.17.27", 17 | "@types/prettier": "2.6.0", 18 | "aws-cdk": "2.27.0", 19 | "jest": "^27.5.1", 20 | "ts-jest": "^27.1.4", 21 | "ts-node": "^10.7.0", 22 | "typescript": "~3.9.7" 23 | }, 24 | "dependencies": { 25 | "aws-cdk-lib": "2.27.0", 26 | "constructs": "^10.0.0", 27 | "source-map-support": "^0.5.21" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2018" 7 | ], 8 | "declaration": true, 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "noImplicitThis": true, 13 | "alwaysStrict": true, 14 | "noUnusedLocals": false, 15 | "noUnusedParameters": false, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": false, 18 | "inlineSourceMap": true, 19 | "inlineSources": true, 20 | "experimentalDecorators": true, 21 | "strictPropertyInitialization": false, 22 | "typeRoots": [ 23 | "./node_modules/@types" 24 | ] 25 | }, 26 | "exclude": [ 27 | "node_modules", 28 | "cdk.out" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /lib/mapping-templates/Mutation.createComment.request.vtl: -------------------------------------------------------------------------------- 1 | #set($id = $util.autoUlid()) 2 | 3 | { 4 | "version" : "2018-05-29", 5 | "operation" : "PutItem", 6 | "key" : { 7 | "PK": $util.dynamodb.toDynamoDBJson("POST#$ctx.args.input.postId"), 8 | "SK": $util.dynamodb.toDynamoDBJson("COMMENT#${id}"), 9 | }, 10 | "attributeValues" : { 11 | "_TYPE": $util.dynamodb.toDynamoDBJson("COMMENT"), 12 | "id": $util.dynamodb.toDynamoDBJson($id), 13 | "postId": $util.dynamodb.toDynamoDBJson($ctx.args.input.postId), 14 | "publishDate": $util.dynamodb.toDynamoDBJson($util.defaultIfNullOrEmpty($ctx.args.input.publishDate, $util.time.nowISO8601())), 15 | "username": $util.dynamodb.toDynamoDBJson($ctx.args.input.username), 16 | "content": $util.dynamodb.toDynamoDBJson($ctx.args.input.content), 17 | } 18 | } -------------------------------------------------------------------------------- /lib/mapping-templates/Mutation.createPost.request.vtl: -------------------------------------------------------------------------------- 1 | #set($id = $util.autoUlid()) 2 | 3 | { 4 | "version" : "2018-05-29", 5 | "operation" : "PutItem", 6 | "key" : { 7 | "PK": $util.dynamodb.toDynamoDBJson("SITE#${ctx.args.input.domain}"), 8 | "SK": $util.dynamodb.toDynamoDBJson("POST#${id}") 9 | }, 10 | "attributeValues" : { 11 | "_TYPE": $util.dynamodb.toDynamoDBJson("POST"), 12 | "id": $util.dynamodb.toDynamoDBJson($id), 13 | "title": $util.dynamodb.toDynamoDBJson($ctx.args.input.title), 14 | "content": $util.dynamodb.toDynamoDBJson($ctx.args.input.content), 15 | "publishDate": $util.dynamodb.toDynamoDBJson($util.defaultIfNullOrEmpty($ctx.args.input.publishDate, $util.time.nowISO8601())) 16 | }, 17 | "condition": { 18 | "expression": "attribute_not_exists(PK)", 19 | } 20 | } -------------------------------------------------------------------------------- /bin/single-table-cdk.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import * as cdk from 'aws-cdk-lib'; 4 | import { SingleTableCdkStack } from '../lib/single-table-cdk-stack'; 5 | 6 | const app = new cdk.App(); 7 | new SingleTableCdkStack(app, 'SingleTableCdkStack', { 8 | /* If you don't specify 'env', this stack will be environment-agnostic. 9 | * Account/Region-dependent features and context lookups will not work, 10 | * but a single synthesized template can be deployed anywhere. */ 11 | 12 | /* Uncomment the next line to specialize this stack for the AWS Account 13 | * and Region that are implied by the current CLI configuration. */ 14 | // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }, 15 | 16 | /* Uncomment the next line if you know exactly what Account and Region you 17 | * want to deploy the stack to. */ 18 | // env: { account: '123456789012', region: 'us-east-1' }, 19 | 20 | /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */ 21 | }); -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/single-table-cdk.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, 21 | "@aws-cdk/core:stackRelativeExports": true, 22 | "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, 23 | "@aws-cdk/aws-lambda:recognizeVersionProps": true, 24 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 25 | "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true, 26 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 27 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 28 | "@aws-cdk/core:checkSecretUsage": true, 29 | "@aws-cdk/aws-iam:minimizePolicies": true, 30 | "@aws-cdk/core:target-partitions": [ 31 | "aws", 32 | "aws-cn" 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/schema.graphql: -------------------------------------------------------------------------------- 1 | schema { 2 | query: Query 3 | mutation: Mutation 4 | } 5 | 6 | type Query { 7 | getSite(domain: String!): Site 8 | getPostsForSite(domain: String!, num: Int, after: String): PostConnection 9 | } 10 | 11 | type Mutation { 12 | createSite(input: CreateSiteInput): Site 13 | createPost(input: CreatePostInput): Post 14 | createComment(input: CreateCommentInput): Comment 15 | } 16 | 17 | type Site { 18 | id: ID! 19 | name: String! 20 | domain: String! 21 | posts: PostConnection! 22 | } 23 | 24 | type PostConnection { 25 | cursor: String 26 | posts: [Post] 27 | } 28 | 29 | type Post { 30 | id: ID! 31 | title: String! 32 | publishDate: AWSDateTime! 33 | content: String! 34 | comments(num: Int, after: String): CommentConnection! 35 | } 36 | 37 | type CommentConnection { 38 | cursor: String 39 | comments: [Comment] 40 | } 41 | 42 | type Comment { 43 | id: ID! 44 | username: String! 45 | content: String! 46 | publishDate: AWSDateTime! 47 | } 48 | 49 | input CreateSiteInput { 50 | name: String 51 | domain: String! 52 | } 53 | 54 | input CreatePostInput { 55 | domain: String! 56 | title: String! 57 | publishDate: AWSDateTime 58 | content: String! 59 | } 60 | 61 | input CreateCommentInput { 62 | postId: String! 63 | username: String! 64 | publishDate: AWSDateTime 65 | content: String! 66 | } 67 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 23 | 24 | 31 | 35 | 39 | 40 | 45 | 46 | 47 | 48 | 49 |
Loading...
50 | 54 | 83 | 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## GraphQL + DynamoDB -- Single-table example 2 | 3 | This repository includes an example of building a GraphQL API with DynamoDB using a single DynamoDB table. It is intended to pair with [this guide on using DynamoDB in a GraphQL API](https://aws.amazon.com/graphql/graphql-dynamodb-data-modeling/). 4 | 5 | ### Table of Contents 6 | 7 | - [Usage](#usage) 8 | - [Application background](#application-background) 9 | - [Takeaways](#takeaways) 10 | 11 | ## Usage 12 | 13 | This application uses the [AWS CDK](https://docs.aws.amazon.com/cdk/v2/guide/home.html) to deploy a GraphQL API to [AWS AppSync](https://aws.amazon.com/appsync/). Be sure to [install and bootstrap the CDK](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html#getting_started_install) before use. 14 | 15 | To deploy, clone this repository and run the following commands: 16 | 17 | ``` 18 | npm i 19 | cdk deploy 20 | ``` 21 | 22 | This will deploy an AppSync API and return the GraphQL root URL and an API key to access the API. 23 | 24 | You can query the API in the AWS console, or you can open the [`index.html`](./index.html) file to use a local GraphQL explorer. Before opening the file, be sure to replace `GRAPHQL_ROOT` and `API_KEY` with the received values from your deploy. 25 | 26 | ## Application background 27 | 28 | This sample application builds a portion of a SaaS blog hosting platform. Users create a Site on the platform and are able to create Posts on the site. Other users can view the Posts and attach a Comment to a Post. 29 | 30 | The simplified ERD is below. This application includes the general CDK code and AppSync resolvers to demonstrate the key differences between single-table and multi-table design with GraphQL + DynamoDB. 31 | 32 | ![AppSync - ERD](https://user-images.githubusercontent.com/6509926/172209448-98350f3f-7fcf-4a7e-aa64-123dd59ab4e9.svg) 33 | 34 | ## Takeaways 35 | 36 | - **The DynamoDB data modeling uses common single-table design principles.** The DynamoDB table has a composite primary key using the generic `PK` and `SK` for the attribute names. Further, each item written to the table includes a `_TYPE` attribute to help distinguish the particular type. 37 | 38 | - **Single-table design optimizes for latency over simplicity.** In using a single-table design, you can fetch multiple types of entities in a single request, as shown in the getSite Query request and response templates. 39 | 40 | Notice the request template does a lookahead to see if the incoming query is asking for the `posts` property on the `site` entity and adjusts its DynamoDB Query accordingly. Further, the response template needs to prepare a response from the result set containing both Site and Post items. 41 | 42 | - **The single-table model adds some constraints.** In this single-table model, both Site and Post items use the Site's `domain` as the partition key. This enables us to fetch both in a single request. 43 | 44 | In our example repository showing the same application with a multi-table design, the Post items use a `siteId` as the partition key. Because Post items are retrieved after the Site is retrieved, it can use a more normalized model. This makes it easier to change the domain of Site and Post items in that example, at the cost of increased latency for common requests. 45 | -------------------------------------------------------------------------------- /lib/single-table-cdk-stack.ts: -------------------------------------------------------------------------------- 1 | import { Stack, StackProps } from 'aws-cdk-lib'; 2 | import { AttributeType, BillingMode, Table } from 'aws-cdk-lib/aws-dynamodb'; 3 | import { Construct } from 'constructs'; 4 | import { AuthorizationType, FieldLogLevel, GraphqlApi, MappingTemplate, Schema } from "@aws-cdk/aws-appsync-alpha"; 5 | import { CfnApiKey } from 'aws-cdk-lib/aws-appsync'; 6 | import { CfnOutput } from 'aws-cdk-lib'; 7 | 8 | export class SingleTableCdkStack extends Stack { 9 | constructor(scope: Construct, id: string, props?: StackProps) { 10 | super(scope, id, props); 11 | 12 | const dynamodbTable = new Table(this, 'DynamoDBTable', { 13 | partitionKey: { name: 'PK', type: AttributeType.STRING}, 14 | sortKey: { name: 'SK', type: AttributeType.STRING}, 15 | billingMode: BillingMode.PAY_PER_REQUEST 16 | }) 17 | 18 | 19 | const api = new GraphqlApi(this, 'Api', { 20 | name: 'DynamoDBSingleTable', 21 | schema: Schema.fromAsset('lib/schema.graphql'), 22 | authorizationConfig: { 23 | defaultAuthorization: { 24 | authorizationType: AuthorizationType.API_KEY 25 | } 26 | }, 27 | logConfig: { 28 | fieldLogLevel: FieldLogLevel.ALL 29 | }, 30 | xrayEnabled: true 31 | }) 32 | 33 | const tableDatasource = api.addDynamoDbDataSource('DynamoDBTable', dynamodbTable) 34 | 35 | tableDatasource.createResolver({ 36 | typeName: 'Mutation', 37 | fieldName: 'createSite', 38 | requestMappingTemplate: MappingTemplate.fromFile('lib/mapping-templates/Mutation.createSite.request.vtl'), 39 | responseMappingTemplate: MappingTemplate.fromFile('lib/mapping-templates/Mutation.createSite.response.vtl'), 40 | }) 41 | 42 | tableDatasource.createResolver({ 43 | typeName: 'Query', 44 | fieldName: 'getSite', 45 | requestMappingTemplate: MappingTemplate.fromFile('lib/mapping-templates/Query.getSite.request.vtl'), 46 | responseMappingTemplate: MappingTemplate.fromFile('lib/mapping-templates/Query.getSite.response.vtl'), 47 | }) 48 | 49 | tableDatasource.createResolver({ 50 | typeName: 'Mutation', 51 | fieldName: 'createPost', 52 | requestMappingTemplate: MappingTemplate.fromFile('lib/mapping-templates/Mutation.createPost.request.vtl'), 53 | responseMappingTemplate: MappingTemplate.fromFile('lib/mapping-templates/Mutation.createPost.response.vtl'), 54 | }) 55 | 56 | tableDatasource.createResolver({ 57 | typeName: 'Query', 58 | fieldName: 'getPostsForSite', 59 | requestMappingTemplate: MappingTemplate.fromFile('lib/mapping-templates/Query.getPostsForSite.request.vtl'), 60 | responseMappingTemplate: MappingTemplate.fromFile('lib/mapping-templates/Query.getPostsForSite.response.vtl'), 61 | }) 62 | 63 | tableDatasource.createResolver({ 64 | typeName: 'Mutation', 65 | fieldName: 'createComment', 66 | requestMappingTemplate: MappingTemplate.fromFile('lib/mapping-templates/Mutation.createComment.request.vtl'), 67 | responseMappingTemplate: MappingTemplate.fromFile('lib/mapping-templates/Mutation.createComment.response.vtl'), 68 | }) 69 | 70 | tableDatasource.createResolver({ 71 | typeName: 'Post', 72 | fieldName: 'comments', 73 | requestMappingTemplate: MappingTemplate.fromFile('lib/mapping-templates/Post.comments.request.vtl'), 74 | responseMappingTemplate: MappingTemplate.fromFile('lib/mapping-templates/Post.comments.response.vtl'), 75 | }) 76 | 77 | const apiKey = new CfnApiKey(this, 'GraphQLApiKey', { 78 | apiId: api.apiId 79 | }) 80 | 81 | new CfnOutput(this, 'GraphQLURLOutput', { 82 | value: api.graphqlUrl, 83 | exportName: 'GraphQLURL' 84 | }) 85 | 86 | new CfnOutput(this, 'GraphQLApiKeyOutput', { 87 | value: apiKey.attrApiKey, 88 | exportName: 'GraphQLApiKey' 89 | }) 90 | } 91 | } 92 | --------------------------------------------------------------------------------