AWS CDK v2 で AppSync を作ってみた

# はじめに

AWS 上のリソースを IaC として定義して、自動デプロイをやりやすくする CDK (AWS Cloud Development Kit) があります。CDK をそれほど触ったことがなかったので、今回は AppSync DynamoDB を CDK でデプロイしてみます。

また、CDK は、CDK v1 と CDK v2 の 2 種類があります。CDK v1 のサポートは、2023 年 6月に終了する予定です。なので、CDK v2 を使ったデプロイ方法を整理していきます。



# CDK Install

まず初めに、CDK をインストールしないといけません。Node.js のバージョン 16 を nvm を使ってインストールしたうえで、`aws-cdk` をインストールしています。

```
nvm use v16.18.0
npm install -g aws-cdk
```

　

CDK のバージョンを確認します。この記事の時点で最新の `2.49.0` がインストールされました。

```
> cdk --version
2.49.0 (build 793dd76)
```



# CDK のプロジェクト作成

手元の環境で、CDK のプロジェクトを作成します。インターネット上で情報が豊富な typescript を選びます。

```
cd ~/temp/cdkdir
mkdir cdk-appsync
cd cdk-appsync
cdk init app --language typescript
```

　

`cdk init app` コマンドにより、いろいろ自動作成されました。

```
> ls -la
total 336
drwxr-xr-x   7 ec2-user docker    229 Oct 29 00:56 ./
drwxr-xr-x   3 ec2-user docker     25 Oct 28 23:59 ../
drwxr-xr-x   2 ec2-user docker     28 Oct 29 00:56 bin/
-rw-r--r--   1 ec2-user docker   1412 Oct 29 00:56 cdk.json
drwxr-xr-x   8 ec2-user docker    184 Oct 29 00:56 .git/
-rw-r--r--   1 ec2-user docker     93 Oct 29 00:56 .gitignore
-rw-r--r--   1 ec2-user docker    157 Oct 29 00:56 jest.config.js
drwxr-xr-x   2 ec2-user docker     34 Oct 29 00:56 lib/
drwxr-xr-x 277 ec2-user docker   8192 Oct 29 00:56 node_modules/
-rw-r--r--   1 ec2-user docker     65 Oct 29 00:56 .npmignore
-rw-r--r--   1 ec2-user docker    573 Oct 29 00:56 package.json
-rw-r--r--   1 ec2-user docker 301122 Oct 29 00:56 package-lock.json
-rw-r--r--   1 ec2-user docker    536 Oct 29 00:56 README.md
drwxr-xr-x   2 ec2-user docker     33 Oct 29 00:56 test/
-rw-r--r--   1 ec2-user docker    650 Oct 29 00:56 tsconfig.json
```

　

まず、テンプレートの時点で、`cdk deploy` を行います。手を加える前に正しくデプロイできるか確認しておきましょう。

```
cdk deploy
```

　

`cdk deploy` により CloudFormation の Stack が作成されました。Stack の中には CDK Medatata が作成されています。正常に CDK が動作することが確認できました。

![image-20221029013401887.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1002774/1b075497-a62f-18c8-752e-407de43ced70.png)



# CDK コードを作成する

このプロジェクト上で、CDK を使って、AppSync とそれに紐づく DynamoDB を作成していきます。Document を読み解いて CDK を記載するのは、初めはちょっと大変なので、サンプルコードから引っ張ってきます。

AWS が管理している [Repository](https://github.com/aws-samples/aws-cdk-examples) に、各言語での様々なサンプルが掲載されています。今回は、この中から以下のサンプルコードをほぼ丸パクリしていきます。

- https://github.com/aws-samples/aws-cdk-examples/blob/master/typescript/appsync-graphql-dynamodb/index.ts

　

今回の構成では、`lib/cdk-appsync-stack.ts` のファイルを編集して、AppSync と DynamoDB の定義を行います。わかりやすいように、全部のコードを記載します。後から大事なポイントを記載するので、これは読み飛ばして頂いて大丈夫です。

```typescript
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { CfnGraphQLApi, CfnApiKey, CfnGraphQLSchema, CfnDataSource, CfnResolver } from 'aws-cdk-lib/aws-appsync';
import { Table, AttributeType, StreamViewType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
import { Role, ServicePrincipal, ManagedPolicy } from 'aws-cdk-lib/aws-iam';

export class CdkAppsyncStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const tableName = 'items'

    const itemsGraphQLApi = new CfnGraphQLApi(this, 'ItemsApi', {
      name: 'items-api',
      authenticationType: 'API_KEY'
    });

    new CfnApiKey(this, 'ItemsApiKey', {
      apiId: itemsGraphQLApi.attrApiId
    });

    const apiSchema = new CfnGraphQLSchema(this, 'ItemsSchema', {
      apiId: itemsGraphQLApi.attrApiId,
      definition: `type ${tableName} {
        ${tableName}Id: ID!
        name: String
      }
      type Paginated${tableName} {
        items: [${tableName}!]!
        nextToken: String
      }
      type Query {
        all(limit: Int, nextToken: String): Paginated${tableName}!
        getOne(${tableName}Id: ID!): ${tableName}
      }
      type Mutation {
        save(name: String!): ${tableName}
        delete(${tableName}Id: ID!): ${tableName}
      }
      type Schema {
        query: Query
        mutation: Mutation
      }`
    });

    const itemsTable = new Table(this, 'ItemsTable', {
      tableName: tableName,
      partitionKey: {
        name: `${tableName}Id`,
        type: AttributeType.STRING
      },
      billingMode: BillingMode.PAY_PER_REQUEST,
      stream: StreamViewType.NEW_IMAGE,

      // The default removal policy is RETAIN, which means that cdk destroy will not attempt to delete
      // the new table, and it will remain in your account until manually deleted. By setting the policy to
      // DESTROY, cdk destroy will delete the table (even if it has data in it)
      removalPolicy: cdk.RemovalPolicy.DESTROY, // NOT recommended for production code
    });

    const itemsTableRole = new Role(this, 'ItemsDynamoDBRole', {
      assumedBy: new ServicePrincipal('appsync.amazonaws.com')
    });

    itemsTableRole.addManagedPolicy(ManagedPolicy.fromAwsManagedPolicyName('AmazonDynamoDBFullAccess'));

    const dataSource = new CfnDataSource(this, 'ItemsDataSource', {
      apiId: itemsGraphQLApi.attrApiId,
      name: 'ItemsDynamoDataSource',
      type: 'AMAZON_DYNAMODB',
      dynamoDbConfig: {
        tableName: itemsTable.tableName,
        awsRegion: this.region
      },
      serviceRoleArn: itemsTableRole.roleArn
    });

    const getOneResolver = new CfnResolver(this, 'GetOneQueryResolver', {
      apiId: itemsGraphQLApi.attrApiId,
      typeName: 'Query',
      fieldName: 'getOne',
      dataSourceName: dataSource.name,
      requestMappingTemplate: `{
        "version": "2017-02-28",
        "operation": "GetItem",
        "key": {
          "${tableName}Id": $util.dynamodb.toDynamoDBJson($ctx.args.${tableName}Id)
        }
      }`,
      responseMappingTemplate: `$util.toJson($ctx.result)`
    });
    getOneResolver.addDependsOn(apiSchema);

    const getAllResolver = new CfnResolver(this, 'GetAllQueryResolver', {
      apiId: itemsGraphQLApi.attrApiId,
      typeName: 'Query',
      fieldName: 'all',
      dataSourceName: dataSource.name,
      requestMappingTemplate: `{
        "version": "2017-02-28",
        "operation": "Scan",
        "limit": $util.defaultIfNull($ctx.args.limit, 20),
        "nextToken": $util.toJson($util.defaultIfNullOrEmpty($ctx.args.nextToken, null))
      }`,
      responseMappingTemplate: `$util.toJson($ctx.result)`
    });
    getAllResolver.addDependsOn(apiSchema);

    const saveResolver = new CfnResolver(this, 'SaveMutationResolver', {
      apiId: itemsGraphQLApi.attrApiId,
      typeName: 'Mutation',
      fieldName: 'save',
      dataSourceName: dataSource.name,
      requestMappingTemplate: `{
        "version": "2017-02-28",
        "operation": "PutItem",
        "key": {
          "${tableName}Id": { "S": "$util.autoId()" }
        },
        "attributeValues": {
          "name": $util.dynamodb.toDynamoDBJson($ctx.args.name)
        }
      }`,
      responseMappingTemplate: `$util.toJson($ctx.result)`
    });
    saveResolver.addDependsOn(apiSchema);

    const deleteResolver = new CfnResolver(this, 'DeleteMutationResolver', {
      apiId: itemsGraphQLApi.attrApiId,
      typeName: 'Mutation',
      fieldName: 'delete',
      dataSourceName: dataSource.name,
      requestMappingTemplate: `{
        "version": "2017-02-28",
        "operation": "DeleteItem",
        "key": {
          "${tableName}Id": $util.dynamodb.toDynamoDBJson($ctx.args.${tableName}Id)
        }
      }`,
      responseMappingTemplate: `$util.toJson($ctx.result)`
    });
    deleteResolver.addDependsOn(apiSchema);

  }
}
```

　

大事なポイントをいくつか抜粋します。まず、冒頭にある import の部分です。CDK を利用する際には、ライブラリとして提供されているので、CDK 上で定義したいものに合わせて import を行います。今回は、AppSync, DynamoDB, IAM を定義するので、これらに関するものを import しています。

```typescript
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { CfnGraphQLApi, CfnApiKey, CfnGraphQLSchema, CfnDataSource, CfnResolver } from 'aws-cdk-lib/aws-appsync';
import { Table, AttributeType, StreamViewType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
import { Role, ServicePrincipal, ManagedPolicy } from 'aws-cdk-lib/aws-iam';
```

　

AppSync で GraphQL API を宣言しているところはこちらです。AppSync の API 名を `items-api` としています。認証タイプは `API_KEY` です。

```typescript
    const itemsGraphQLApi = new CfnGraphQLApi(this, 'ItemsApi', {
      name: 'items-api',
      authenticationType: 'API_KEY'
    });
```

　

AppSync のスキーマを定義する箇所はここです。通常の AppSync であれば graphql.schema で定義しますが、CDK の場合は TypeScript のコードの中に埋め込みます。コードの中には変数も使えるので、プログラム上のメリットが活かせるのが良い点ですね。

```typescript
    const tableName = 'items' 
    
    const apiSchema = new CfnGraphQLSchema(this, 'ItemsSchema', {
      apiId: itemsGraphQLApi.attrApiId,
      definition: `type ${tableName} {
        ${tableName}Id: ID!
        name: String
      }
      type Paginated${tableName} {
        items: [${tableName}!]!
        nextToken: String
      }
      type Query {
        all(limit: Int, nextToken: String): Paginated${tableName}!
        getOne(${tableName}Id: ID!): ${tableName}
      }
      type Mutation {
        save(name: String!): ${tableName}
        delete(${tableName}Id: ID!): ${tableName}
      }
      type Schema {
        query: Query
        mutation: Mutation
      }`
    });
```

　

上記の定義により、次の `graphql.schema` が生成されます。`${tableName}` の変数が、それぞれ展開されて定義される形です。

```
type items {
	itemsId: ID!
	name: String
}

type Paginateditems {
	items: [items!]!
	nextToken: String
}

type Query {
	all(limit: Int, nextToken: String): Paginateditems!
	getOne(itemsId: ID!): items
}

type Mutation {
	save(name: String!): items
	delete(itemsId: ID!): items
}

type Schema {
	query: Query
	mutation: Mutation
}
```

　

DynamoDB のテーブル定義はこんな感じです。テーブル名や、パーティションキーなどが定義されています。

```typescript
    const itemsTable = new Table(this, 'ItemsTable', {
      tableName: tableName,
      partitionKey: {
        name: `${tableName}Id`,
        type: AttributeType.STRING
      },
      billingMode: BillingMode.PAY_PER_REQUEST,
      stream: StreamViewType.NEW_IMAGE,

      // The default removal policy is RETAIN, which means that cdk destroy will not attempt to delete
      // the new table, and it will remain in your account until manually deleted. By setting the policy to
      // DESTROY, cdk destroy will delete the table (even if it has data in it)
      removalPolicy: cdk.RemovalPolicy.DESTROY, // NOT recommended for production code
    });
```

　

AppSync のデータソースの定義や、それを使ったリゾルバーの定義がこの辺りです。データソースとして、DynamoDB のテーブルが定義されています。また、リゾルバーとして `getOne` という Query が定義されています。裏側では、DynamoDB に対して `GetItem` がされています。`"${tableName}Id": $util.dynamodb.toDynamoDBJson($ctx.args.${tableName}Id)` の定義により、AppSync のパラメータとして渡された `ItemsId` のデータを取得する内容となります。これは、CDK というよりも、AppSync のマッピングテンプレートの仕組みですね。

```typescript
    const dataSource = new CfnDataSource(this, 'ItemsDataSource', {
      apiId: itemsGraphQLApi.attrApiId,
      name: 'ItemsDynamoDataSource',
      type: 'AMAZON_DYNAMODB',
      dynamoDbConfig: {
        tableName: itemsTable.tableName,
        awsRegion: this.region
      },
      serviceRoleArn: itemsTableRole.roleArn
    });

    const getOneResolver = new CfnResolver(this, 'GetOneQueryResolver', {
      apiId: itemsGraphQLApi.attrApiId,
      typeName: 'Query',
      fieldName: 'getOne',
      dataSourceName: dataSource.name,
      requestMappingTemplate: `{
        "version": "2017-02-28",
        "operation": "GetItem",
        "key": {
          "${tableName}Id": $util.dynamodb.toDynamoDBJson($ctx.args.${tableName}Id)
        }
      }`,
      responseMappingTemplate: `$util.toJson($ctx.result)`
    });
    getOneResolver.addDependsOn(apiSchema);
```



# CDK デプロイ

これらの CDK のコードを書いたあとに、cdk diff を実行すると、現在からの差分を確認できます。AppSync や DynamoDB や IAM Role が新規追加される様子がわかります。

```
> cdk diff
Stack CdkAppsyncStack
IAM Statement Changes
┌───┬──────────────────────────┬────────┬────────────────┬───────────────────────────────┬───────────┐
│   │ Resource                 │ Effect │ Action         │ Principal                     │ Condition │
├───┼──────────────────────────┼────────┼────────────────┼───────────────────────────────┼───────────┤
│ + │ ${ItemsDynamoDBRole.Arn} │ Allow  │ sts:AssumeRole │ Service:appsync.amazonaws.com │           │
└───┴──────────────────────────┴────────┴────────────────┴───────────────────────────────┴───────────┘
IAM Policy Changes
┌───┬──────────────────────┬────────────────────────────────────────────────────────────────┐
│   │ Resource             │ Managed Policy ARN                                             │
├───┼──────────────────────┼────────────────────────────────────────────────────────────────┤
│ + │ ${ItemsDynamoDBRole} │ arn:${AWS::Partition}:iam::aws:policy/AmazonDynamoDBFullAccess │
└───┴──────────────────────┴────────────────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Resources
[+] AWS::AppSync::GraphQLApi ItemsApi ItemsApi 
[+] AWS::AppSync::ApiKey ItemsApiKey ItemsApiKey 
[+] AWS::AppSync::GraphQLSchema ItemsSchema ItemsSchema 
[+] AWS::DynamoDB::Table ItemsTable ItemsTable5AAC2C46 
[+] AWS::IAM::Role ItemsDynamoDBRole ItemsDynamoDBRole7D2E3F6D 
[+] AWS::AppSync::DataSource ItemsDataSource ItemsDataSource 
[+] AWS::AppSync::Resolver GetOneQueryResolver GetOneQueryResolver 
[+] AWS::AppSync::Resolver GetAllQueryResolver GetAllQueryResolver 
[+] AWS::AppSync::Resolver SaveMutationResolver SaveMutationResolver 
[+] AWS::AppSync::Resolver DeleteMutationResolver DeleteMutationResolver 
```

　

cdk deploy で実際にデプロイを行います。

```
cdk deploy
```

　

CloudFormation では、このように CDK で定義した各種リソースがデプロイされている様子がわかります。

![image-20221029022057037.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1002774/3d148b1b-a487-3de6-d734-ff76b9eb2110.png)



## AppSync

新しく API が作成され、以下の Schema が定義されています。CDK で指定したものが利用されています。

![image-20221029022521482.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1002774/2678e0fb-b901-d3ff-9bb4-80afcbf3e527.png)



Query の定義はこんな感じです。CDK で定義した 4 つのリゾルバーが見えています。

![image-20221029022841829.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1002774/412d1055-4c40-eeee-a966-c7d27258bc27.png)



## DynamoDB

`items` のテーブルが作成されています。

![image-20221029022626202.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1002774/7f20d5d2-3d2e-67d4-b842-51ab0b1e2aae.png)



# AppSync で操作してみる

AppSync の画面で、実際にクエリーを実行してみます。`save` で Mutation をします。

![image-20221029180758510.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1002774/cb441290-abdb-c838-fca0-a8c1e4c7405f.png)



裏側では、しっかり DynamoDB にデータが保存されています。

![image-20221029180857972.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1002774/b6428810-4146-0294-15eb-bf6c7fc77908.png)



次に、AppSync でクエリーをして、先ほど格納したデータを取得できるか確認します。`all` ですべての item を取得してみると、先ほど格納した 1 件のデータが取得できました。複数のデータを格納すると、複数のデータを取得できます。

![image-20221029180951527.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1002774/83bf7261-48e2-2ec7-3d50-30586b7eb7f0.png)



# 検証を通じてわかったこと

- CDK v2 のサンプルコードが [GitHub](https://github.com/aws-samples/aws-cdk-examples) で公開されており、まずはそこから検証をし始めるのがやりやすい
- CDK v2 のコード上で、AppSync に紐づく `graphql.schema` の指定や、カスタムリゾルバーの指定を行うことが出来る。



# 参考資料

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-construct-library.html

https://dev.classmethod.jp/articles/aws-cdk-appsync-101/

https://github.com/aws-samples/aws-cdk-examples