├── .gitignore ├── .npmignore ├── README.md ├── bin └── cdk-appsync-chat.ts ├── cdk.json ├── graphql └── schema.graphql ├── header.jpg ├── jest.config.js ├── lib └── cdk-appsync-chat-stack.ts ├── package-lock.json ├── package.json ├── test └── cdk-appsync-chat.test.ts ├── tsconfig.json └── yarn.lock /.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 | 10 | # Parcel default cache directory 11 | .parcel-cache 12 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](header.jpg) 2 | 3 | ## CDK AppSync Chat 4 | 5 | This CDK project deploys an AppSync API, Amazon DynamoDB tables, and an Amazon Cognito User Pool configured to create the infrastructure for a chat application. It is paired with a React front-end for a full-stack serverless project. 6 | 7 | ### Deploying the back end 8 | 9 | 1. Clone the repo 10 | 11 | ```sh 12 | git clone https://github.com/full-stack-serverless/cdk-appsync-chat.git 13 | ``` 14 | 15 | 2. Change into the `cdk-appsync-chat` directory 16 | 17 | 3. Install dependencies in main folder: 18 | 19 | ```sh 20 | npm install 21 | 22 | # or 23 | 24 | yarn 25 | ``` 26 | 27 | 3. Deploy to AWS 28 | 29 | ```sh 30 | cdk deploy 31 | ``` 32 | 33 | Once the project has been deployed, you'll be given the resources needed to configure the client-side React application. 34 | 35 | ```sh 36 | Outputs: 37 | CdkAppsyncChatStack.UserPoolClientId = 4tojuqrgrctupmj28812nmgi2t 38 | CdkAppsyncChatStack.UserPoolId = us-east-1_vS2Qv9tob 39 | CdkAppsyncChatStack.GraphQLAPIURL = https://w6eiaujspbbcvovbqzn2w4zxhu.appsync-api.us-east-1.amazonaws.com/graphql 40 | ``` 41 | 42 | ### Deploying the front end 43 | 44 | 1. Clone the client application 45 | 46 | ```sh 47 | git clone https://github.com/full-stack-serverless/chat-app.git 48 | ``` 49 | 50 | 3. Change into the client directory and install dependencies: 51 | 52 | ```sh 53 | cd chat-app 54 | 55 | npm install 56 | 57 | # or 58 | 59 | yarn 60 | ``` 61 | 62 | 4. Open __src/aws-exports-example.js__ and update with the outputs from CDK. 63 | 64 | 5. Rename __aws-exports-example.js__ to __aws-exports.js__. 65 | 66 | 5. Run the app 67 | 68 | ```sh 69 | npm start 70 | ``` -------------------------------------------------------------------------------- /bin/cdk-appsync-chat.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import * as cdk from '@aws-cdk/core'; 4 | import { CdkAppsyncChatStack } from '../lib/cdk-appsync-chat-stack'; 5 | 6 | const app = new cdk.App(); 7 | new CdkAppsyncChatStack(app, 'CdkAppsyncChatStack'); 8 | -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node bin/cdk-appsync-chat.ts", 3 | "context": { 4 | "@aws-cdk/core:enableStackNameDuplicates": "true", 5 | "aws-cdk:enableDiffNoFail": "true" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /graphql/schema.graphql: -------------------------------------------------------------------------------- 1 | type Message { 2 | id: ID! 3 | content: String! 4 | owner: String 5 | createdAt: String 6 | roomId: ID 7 | } 8 | 9 | type Room { 10 | id: ID! 11 | name: String 12 | messages( 13 | sortDirection: ModelSortDirection, 14 | limit: Int, 15 | nextToken: String 16 | ): MessageConnection 17 | createdAt: AWSDateTime 18 | updatedAt: AWSDateTime 19 | } 20 | 21 | enum ModelSortDirection { 22 | ASC 23 | DESC 24 | } 25 | 26 | type MessageConnection { 27 | items: [Message] 28 | nextToken: String 29 | } 30 | 31 | type RoomConnection { 32 | items: [Room] 33 | nextToken: String 34 | } 35 | 36 | type Query { 37 | getRoom(id: ID): Room 38 | listMessagesForRoom(roomId: ID, sortDirection: ModelSortDirection): MessageConnection 39 | listRooms(limit: Int): RoomConnection 40 | } 41 | 42 | type Mutation { 43 | createMessage(input: MessageInput): Message 44 | createRoom(input: RoomInput): Room 45 | } 46 | 47 | input MessageInput { 48 | id: ID 49 | content: String! 50 | owner: String 51 | createdAt: String 52 | roomId: ID 53 | } 54 | 55 | input RoomInput { 56 | id: ID 57 | name: String 58 | } 59 | 60 | type Subscription { 61 | onCreateRoom: Room 62 | @aws_subscribe(mutations: ["createRoom"]) 63 | onCreateMessageByRoomId(roomId: ID): Message 64 | @aws_subscribe(mutations: ["createMessage"]) 65 | } -------------------------------------------------------------------------------- /header.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/full-stack-serverless/cdk-appsync-chat/0f971461d2483253f10c9c9f946133e3073fd992/header.jpg -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/test'], 3 | testMatch: ['**/*.test.ts'], 4 | transform: { 5 | '^.+\\.tsx?$': 'ts-jest' 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /lib/cdk-appsync-chat-stack.ts: -------------------------------------------------------------------------------- 1 | import { Construct, StackProps, CfnOutput, Stack } from '@aws-cdk/core'; 2 | import { UserPool, VerificationEmailStyle, UserPoolClient, AccountRecovery } from '@aws-cdk/aws-cognito' 3 | import { GraphqlApi, AuthorizationType, FieldLogLevel, MappingTemplate, Schema, UserPoolDefaultAction } from '@aws-cdk/aws-appsync' 4 | import { AttributeType, BillingMode, Table } from '@aws-cdk/aws-dynamodb'; 5 | import { Role, ServicePrincipal, Effect, PolicyStatement } from '@aws-cdk/aws-iam' 6 | 7 | export class CdkAppsyncChatStack extends Stack { 8 | constructor(scope: Construct, id: string, props?: StackProps) { 9 | super(scope, id, props); 10 | 11 | const userPool = new UserPool(this, 'cdk-chat-app-user-pool', { 12 | selfSignUpEnabled: true, 13 | accountRecovery: AccountRecovery.PHONE_AND_EMAIL, 14 | userVerification: { 15 | emailStyle: VerificationEmailStyle.CODE 16 | }, 17 | autoVerify: { 18 | email: true 19 | }, 20 | standardAttributes: { 21 | email: { 22 | required: true, 23 | mutable: true 24 | } 25 | } 26 | }); 27 | 28 | const userPoolClient = new UserPoolClient(this, "UserPoolClient", { 29 | userPool 30 | }); 31 | 32 | new CfnOutput(this, "UserPoolId", { 33 | value: userPool.userPoolId 34 | }); 35 | 36 | new CfnOutput(this, "UserPoolClientId", { 37 | value: userPoolClient.userPoolClientId 38 | }); 39 | 40 | const api = new GraphqlApi(this, 'cdk-chat-app', { 41 | name: "cdk-chat-app", 42 | logConfig: { 43 | fieldLogLevel: FieldLogLevel.ALL, 44 | }, 45 | schema: Schema.fromAsset('./graphql/schema.graphql'), 46 | authorizationConfig: { 47 | defaultAuthorization: { 48 | authorizationType: AuthorizationType.USER_POOL, 49 | userPoolConfig: { 50 | userPool: userPool, 51 | defaultAction: UserPoolDefaultAction.ALLOW, 52 | } 53 | }, 54 | }, 55 | }); 56 | 57 | new CfnOutput(this, "GraphQLAPIURL", { 58 | value: api.graphqlUrl 59 | }); 60 | 61 | const messageTable = new Table(this, 'CDKMessageTable', { 62 | billingMode: BillingMode.PAY_PER_REQUEST, 63 | partitionKey: { 64 | name: 'id', 65 | type: AttributeType.STRING, 66 | }, 67 | }); 68 | 69 | const messageTableServiceRole = new Role(this, 'MessageTableServiceRole', { 70 | assumedBy: new ServicePrincipal('dynamodb.amazonaws.com') 71 | }); 72 | 73 | messageTableServiceRole.addToPolicy( 74 | new PolicyStatement({ 75 | effect: Effect.ALLOW, 76 | resources: [`${messageTable.tableArn}/index/messages-by-room-id`], 77 | actions: [ 78 | 'dymamodb:Query' 79 | ] 80 | }) 81 | ); 82 | 83 | const roomTable = new Table(this, 'CDKRoomTable', { 84 | billingMode: BillingMode.PAY_PER_REQUEST, 85 | partitionKey: { 86 | name: 'id', 87 | type: AttributeType.STRING, 88 | }, 89 | }); 90 | 91 | messageTable.addGlobalSecondaryIndex({ 92 | indexName: 'messages-by-room-id', 93 | partitionKey: { 94 | name: 'roomId', 95 | type: AttributeType.STRING 96 | }, 97 | sortKey: { 98 | name: 'createdAt', 99 | type: AttributeType.STRING 100 | } 101 | }) 102 | 103 | const messageTableDs = api.addDynamoDbDataSource('Message', messageTable); 104 | const roomTableDs = api.addDynamoDbDataSource('Room', roomTable); 105 | 106 | messageTableDs.createResolver({ 107 | typeName: 'Query', 108 | fieldName: 'listMessagesForRoom', 109 | requestMappingTemplate: MappingTemplate.fromString(` 110 | { 111 | "version" : "2017-02-28", 112 | "operation" : "Query", 113 | "index" : "messages-by-room-id", 114 | "query" : { 115 | "expression": "roomId = :roomId", 116 | "expressionValues" : { 117 | ":roomId" : $util.dynamodb.toDynamoDBJson($context.arguments.roomId) 118 | } 119 | 120 | } 121 | #if( !$util.isNull($ctx.arguments.sortDirection) 122 | && $ctx.arguments.sortDirection == "DESC" ) 123 | ,"scanIndexForward": false 124 | #else 125 | ,"scanIndexForward": true 126 | #end 127 | #if($context.arguments.nextToken) 128 | ,"nextToken": "$context.arguments.nextToken" 129 | #end 130 | } 131 | `), 132 | responseMappingTemplate: MappingTemplate.fromString(` 133 | #if( $ctx.error ) 134 | $util.error($ctx.error.message, $ctx.error.type) 135 | #else 136 | $util.toJson($ctx.result) 137 | #end` 138 | ) 139 | }) 140 | 141 | messageTableDs.createResolver({ 142 | typeName: 'Mutation', 143 | fieldName: 'createMessage', 144 | requestMappingTemplate: MappingTemplate.fromString(` 145 | ## Automatically set the id if it's not passed in. 146 | $util.qr($context.args.input.put("id", $util.defaultIfNull($ctx.args.input.id, $util.autoId()))) 147 | ## Automatically set the createdAt timestamp. 148 | #set( $createdAt = $util.time.nowISO8601() ) 149 | $util.qr($context.args.input.put("createdAt", $util.defaultIfNull($ctx.args.input.createdAt, $createdAt))) 150 | 151 | ## Automatically set the user's username on owner field. 152 | $util.qr($ctx.args.input.put("owner", $context.identity.username)) 153 | 154 | ## Create a condition that will error if the id already exists 155 | #set( $condition = { 156 | "expression": "attribute_not_exists(#id)", 157 | "expressionNames": { 158 | "#id": "id" 159 | } 160 | } ) 161 | 162 | { 163 | "version": "2018-05-29", 164 | "operation": "PutItem", 165 | "key": { 166 | "id": $util.dynamodb.toDynamoDBJson($ctx.args.input.id) 167 | }, 168 | "attributeValues": $util.dynamodb.toMapValuesJson($context.args.input), 169 | "condition": $util.toJson($condition) 170 | } 171 | `), 172 | responseMappingTemplate: MappingTemplate.dynamoDbResultItem() 173 | }) 174 | 175 | roomTableDs.createResolver({ 176 | typeName: 'Query', 177 | fieldName: 'listRooms', 178 | requestMappingTemplate: MappingTemplate.fromString(` 179 | #set( $limit = $util.defaultIfNull($context.args.limit, 1000) ) 180 | #set( $ListRequest = { 181 | "version": "2018-05-29", 182 | "limit": $limit 183 | } ) 184 | #if( $context.args.nextToken ) 185 | #set( $ListRequest.nextToken = $context.args.nextToken ) 186 | #end 187 | $util.qr($ListRequest.put("operation", "Scan")) 188 | $util.toJson($ListRequest) 189 | `), 190 | responseMappingTemplate: MappingTemplate.fromString(` 191 | #if( $ctx.error) 192 | $util.error($ctx.error.message, $ctx.error.type) 193 | #else 194 | $util.toJson($ctx.result) 195 | #end 196 | `) 197 | }) 198 | 199 | roomTableDs.createResolver({ 200 | typeName: 'Mutation', 201 | fieldName: 'createRoom', 202 | requestMappingTemplate: MappingTemplate.fromString(` 203 | $util.qr($context.args.input.put("id", $util.defaultIfNull($ctx.args.input.id, $util.autoId()))) 204 | { 205 | "version": "2018-05-29", 206 | "operation": "PutItem", 207 | "key": { 208 | "id": $util.dynamodb.toDynamoDBJson($ctx.args.input.id) 209 | }, 210 | "attributeValues": $util.dynamodb.toMapValuesJson($context.args.input), 211 | "condition": $util.toJson($condition) 212 | } 213 | `), 214 | responseMappingTemplate: MappingTemplate.dynamoDbResultItem() 215 | }) 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdk-appsync-chat", 3 | "version": "0.1.0", 4 | "bin": { 5 | "cdk-appsync-chat": "bin/cdk-appsync-chat.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "cdk": "cdk" 12 | }, 13 | "devDependencies": { 14 | "@aws-cdk/assert": "1.62.0", 15 | "@types/jest": "^25.2.1", 16 | "@types/node": "10.17.5", 17 | "aws-cdk": "1.62.0", 18 | "jest": "^25.5.0", 19 | "ts-jest": "^25.3.1", 20 | "ts-node": "^8.1.0", 21 | "typescript": "~3.7.2" 22 | }, 23 | "dependencies": { 24 | "@aws-cdk/aws-appsync": "^1.62.0", 25 | "@aws-cdk/aws-cognito": "^1.62.0", 26 | "@aws-cdk/aws-dynamodb": "^1.62.0", 27 | "@aws-cdk/aws-iam": "^1.62.0", 28 | "@aws-cdk/core": "^1.62.0", 29 | "source-map-support": "^0.5.16" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/cdk-appsync-chat.test.ts: -------------------------------------------------------------------------------- 1 | import { expect as expectCDK, matchTemplate, MatchStyle } from '@aws-cdk/assert'; 2 | import * as cdk from '@aws-cdk/core'; 3 | import * as CdkAppsyncChat from '../lib/cdk-appsync-chat-stack'; 4 | 5 | test('Empty Stack', () => { 6 | const app = new cdk.App(); 7 | // WHEN 8 | const stack = new CdkAppsyncChat.CdkAppsyncChatStack(app, 'MyTestStack'); 9 | // THEN 10 | expectCDK(stack).to(matchTemplate({ 11 | "Resources": {} 12 | }, MatchStyle.EXACT)) 13 | }); 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2018"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization": false, 20 | "typeRoots": ["./node_modules/@types"] 21 | }, 22 | "exclude": ["cdk.out"] 23 | } 24 | --------------------------------------------------------------------------------