CDKでAppSyncを利用する簡単な例

# はじめに
こんにちは。
本日は、CDKでAWS AppSyncを利用する簡単な例について紹介していこうと思います。
CDKは、プログラミング言語を利用して、AWSのクラウドリソースを定義することができます。
また、AWS AppSyncは、GraphQL APIを簡単に開発可能なAWSの完全マネージド型サービスです。

AppSyncを始める前の簡単な読み物として読んでいただければなと思います。

今回は以下のような簡単なGraphQL APIを作っていきます。

```graphql:lib/resources/schema.graphql
type Query {
  getPost(id: ID!): Post
  listPosts(userId: ID!, limit: Int, nextToken: String): PostConnection
}
type Post {
  id: ID!
  userId: ID!
  title: String!
}
type PostConnection {
  items: [Post]
  nextToken: String
}
```

データは、以下のような簡易的なデータを想定しています。

| id | userId | title |
|:--|:--|:--|
| 1  | dfb6123d-87fb-4dda-bb92-84c6228ef39a | Post1 |
| 2  | dd0c99c1-2770-4f13-b39f-6c32f7ea5a28 | Post2 |
| 3  | dd0c99c1-2770-4f13-b39f-6c32f7ea5a28 | Post3 |
| ...  | ... | ... |


# AppSync APIを作成する
まずは、AppSyncのAPIを作成していきます。
認証にはAmazon Cognitoを利用します。

データの保存先としては、DynamoDBを利用します。
`userId`をキーにリスト取得を行うために、`userId`をキーとしたインデックステーブルを作成しています。

```ts:lib/demo-stack.ts
import * as cdk from '@aws-cdk/core';
import * as ddb from '@aws-cdk/aws-dynamodb';
import * as cognito from '@aws-cdk/aws-cognito';
import * as appsync from '@aws-cdk/aws-appsync';

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

    // DynamoDBのテーブル
    const postTable = new ddb.Table(this, 'PostTable', {
      partitionKey: {
        name: 'id',
        type: ddb.AttributeType.STRING,
      },
    });
    // userIdをキーにしたインデックス
    postTable.addGlobalSecondaryIndex({
      indexName: 'gsi-userId',
      partitionKey: {
        name: 'userId',
        type: ddb.AttributeType.STRING,
      },
    });

    // 認証用のAmazon Cognitoユーザープール
    const userPool = new cognito.UserPool(this, 'UserPool');
    const userPoolClient = userPool.addClient('UserPoolClient');

    // AppSync API
    const api = new appsync.GraphqlApi(this, 'AppSyncAPI', {
      name: 'appsync-api',
      schema: appsync.Schema.fromAsset(`lib/resources/schema.graphql`),
      authorizationConfig: {
        defaultAuthorization: {
          authorizationType: appsync.AuthorizationType.USER_POOL,
          userPoolConfig: {
            userPool,
          },
        },
      },
    });
  }
}
```

# GraphQL APIのクエリを作成する
次に、AppSyncでDynamoDBからデータを取得していきたいと思います。
AppSyncでは、様々な方法でデータを取得することができますが、
よくあるユースケースとして、以下の2つを紹介します。

* DynamoDBからデータを直接取得するケース
* Lambda Functionを介してデータを取得するケース

## DynamoDBからデータを直接取得するケース
DynamoDBから直接データを取得する場合は、Apache Velocity Template Language(VTL)という言語を利用して、マッピングテンプレートというものを記述する必要があります。

こんなものです。
変数などの利用が可能で、最終的には取得ルールを記載したJSONデータという形で取り扱われます。

```js
{
  'version': '2017-02-28',
  'operation': 'GetItem',
  'key': {
    'id': $util.dynamodb.toDynamoDBJson($ctx.args.id)
  }
}
```

CDKでは、マッピングテンプレートの記述方法が主に3通りあります。

### パターン1:CDKのマッピングテンプレート用関数を利用する
CDKでAppSyncを作成する場合は、
よくあるユースケースのマッピングテンプレートを簡単に利用することができます。

```ts:lib/demo-stack.ts
import * as cdk from '@aws-cdk/core';
import * as ddb from '@aws-cdk/aws-dynamodb';
import * as cognito from '@aws-cdk/aws-cognito';
import * as appsync from '@aws-cdk/aws-appsync';

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

    ...

    // AppSyncのPostテーブルのデータソースを定義する
    const postTableDataSource = api.addDynamoDbDataSource('PostTableDataSource', postTable);

    // getPostクエリを作成する
    postTableDataSource.createResolver({
      typeName: 'Query',
      fieldName: 'getPost',
      requestMappingTemplate: appsync.MappingTemplate.dynamoDbGetItem('id', 'id'),
      responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
    });

    // listPostsクエリを作成する
    postTableDataSource.createResolver({
      typeName: 'Query',
      fieldName: 'listPosts',
      requestMappingTemplate: appsync.MappingTemplate.dynamoDbQuery(appsync.KeyCondition.eq('userId', 'userId'), 'gsi-userId'),
      responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
    });
  }
}
```

`appsync.MappingTemplate.dynamoDbGetItem('id', 'id')`の記述の場合は、以下のようなマッピングテンプレートを自動で出力してくれます。

```js
{
  'version': '2017-02-28',
  'operation': 'GetItem',
  'key': {
    'id': $util.dynamodb.toDynamoDBJson($ctx.args.id)
  }
}
```

`appsync.MappingTemplate.dynamoDbQuery(appsync.KeyCondition.eq('userId', 'userId'), 'gsi-userId')`の記述の場合は、以下のようなマッピングテンプレートを自動で出力してくれます。

```js
{
  'version' : '2017-02-28',
  'operation' : 'Query',
  'index' : 'gsi-userId',
  'query' : {
    'expression' : '#userId = :userId',
    'expressionNames' : {
      '#userId' : 'userId'
    },
    'expressionValues' : {
      ':userId' : $util.dynamodb.toDynamoDBJson($ctx.args.userId)
    }
  }
}
```

`appsync.MappingTemplate.dynamoDbResultItem()`の記述の場合は、以下のようなマッピングテンプレートを自動で出力してくれます。

```js
$util.toJson($ctx.result)
```

### パターン2:マッピングテンプレートを直接記述する
自前で作成したマッピングテンプレートを直接記載することも可能です。
今回の例では、そのまま自前で記載するメリットはありませんが、以下のように別の処理を追加したい場合などに有効です。
この記述の場合は、取得したユーザーIDが認証したユーザーIDと異なる場合に、Unauthorizedエラーを返します。

```ts
appsync.MappingTemplate.fromString(`
#if ( $ctx.identity.sub != $ctx.result.userId )
  $utils.unauthorized()
#end

$util.toJson($ctx.result)
`)
```

### パターン3:マッピングテンプレートをファイルに記述する
自前で作成したマッピングテンプレートをファイルを介して利用することも可能です。
この記述の場合は、リスト取得の際に、`limit`引数を設定することができます。

```ts:lib/demo-stack.ts
appsync.MappingTemplate.fromFile('lib/resources/listPosts.req.vtl')
```

```ts:lib/resources/listPosts.req.vtl
{
  'version' : '2017-02-28',
  'operation' : 'Query',
  'index' : 'gsi-userId',
  'query' : {
    'expression' : '#userId = :userId',
    'expressionNames' : {
      '#userId' : 'userId'
    },
    'expressionValues' : {
      ':userId' : $util.dynamodb.toDynamoDBJson($ctx.args.userId)
    }
  },
  'limit': $util.defaultIfNull($context.args.limit, 20),
  'nextToken': $util.toJson($util.defaultIfNullOrEmpty($ctx.args.nextToken, null))
}
```

## Lambda Functionを介してデータを取得するケース
VTLでのマッピングテンプレートの利用は、高速であり、簡単なデータの読み取りや書き込みを行いたい場合は、非常に便利です。
ですが、もう少し複雑なユースケースを利用したい場合に、Lambda Functionを介してデータを取得することがあります。

```ts:lib/demo-stack.ts
import * as cdk from '@aws-cdk/core';
import * as ddb from '@aws-cdk/aws-dynamodb';
import * as cognito from '@aws-cdk/aws-cognito';
import * as appsync from '@aws-cdk/aws-appsync';
import * as lambda from '@aws-cdk/aws-lambda';
import * as lambdaNode from '@aws-cdk/aws-lambda-nodejs';

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

    ...

    // AppSync用のLambda Functionを作成する
    const lambdaFunction = new lambdaNode.NodejsFunction(this, 'AppSyncFunction', {
      runtime: lambda.Runtime.NODEJS_14_X,
      entry: 'lib/resources/handler.ts',
      handler: 'handler',
      memorySize: 128,
    });
    // Lambda Functionからテーブル名を参照できるよう環境変数に定義する
    lambdaFunction.addEnvironment('POST_TABLE', postTable.tableName);
    // POSTテーブルのREAD権限をLambda Functionに割り当てる
    postTable.grantReadData(lambdaFunction);

    // AppSyncのLambda Functionデータソースを定義する
    const lambdaFunctionDataSource = api.addLambdaDataSource('LambdaFunctionDataSource', lambdaFunction);

    // getPostクエリの取得先としてLambda Functionデータソースを指定する
    lambdaFunctionDataSource.createResolver({
      typeName: 'Query',
      fieldName: 'getPost',
    });
  }
}
```

```ts:lib/resources/handler.ts
type AppSyncEvent = {
  info: {
    fieldName: string;
  };
  arguments: any;
};

export const handler = async (event: AppSyncEvent, context: any, callback: any) => {
  switch (event.info.fieldName) {
    case 'getPost':
      return getPost(event.arguments.id);
    default:
      console.error(`Unknown field: ${event.info.fieldName}`);
      return callback(`Unknown field: ${event.info.fieldName}`);
  }
};

import { DynamoDB } from 'aws-sdk';

const getPost = async (id: string) => {
  const client = new DynamoDB.DocumentClient();
  const { Item } = await client.get({
    TableName: process.env.POST_TABLE!,
    Key: {
      id,
    },
  }).promise();

  return Item;
}
```

## まとめ
一番シンプルなCDKの記述をまとめます。

```ts:lib/demo-stack.ts
import * as cdk from '@aws-cdk/core';
import * as ddb from '@aws-cdk/aws-dynamodb';
import * as cognito from '@aws-cdk/aws-cognito';
import * as appsync from '@aws-cdk/aws-appsync';

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

    // DynamoDBのテーブル
    const postTable = new ddb.Table(this, 'PostTable', {
      partitionKey: {
        name: 'id',
        type: ddb.AttributeType.STRING,
      },
    });
    // userIdをキーにしたインデックス
    postTable.addGlobalSecondaryIndex({
      indexName: 'gsi-userId',
      partitionKey: {
        name: 'userId',
        type: ddb.AttributeType.STRING,
      },
    });

    // 認証用のAmazon Cognitoユーザープール
    const userPool = new cognito.UserPool(this, 'UserPool');
    const userPoolClient = userPool.addClient('UserPoolClient');

    // AppSync API
    const api = new appsync.GraphqlApi(this, 'AppSyncAPI', {
      name: 'appsync-api',
      schema: appsync.Schema.fromAsset(`lib/resources/schema.graphql`),
      authorizationConfig: {
        defaultAuthorization: {
          authorizationType: appsync.AuthorizationType.USER_POOL,
          userPoolConfig: {
            userPool,
          },
        },
      },
    });

    // AppSyncのPostテーブルのデータソースを定義する
    const postTableDataSource = api.addDynamoDbDataSource('PostTableDataSource', postTable);

    // getPostクエリを作成する
    postTableDataSource.createResolver({
      typeName: 'Query',
      fieldName: 'getPost',
      requestMappingTemplate: appsync.MappingTemplate.dynamoDbGetItem('id', 'id'),
      responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
    });

    // listPostsクエリを作成する
    postTableDataSource.createResolver({
      typeName: 'Query',
      fieldName: 'listPosts',
      requestMappingTemplate: appsync.MappingTemplate.dynamoDbQuery(appsync.KeyCondition.eq('userId', 'userId'), 'gsi-userId'),
      responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
    });

    // 必要に応じて、お好きなクエリやミューテーションを足していきましょう！
  }
}
```

たったこれだけの記述でGraphQL APIができました。すごく簡単ですよね。
ちなみにVTLのケースではIAM権限の話に触れていませんが、実はCDKが自動で必要最低限の権限をAppSyncに割り当ててくれています。

個人的には、データの取得は素のマッピングテンプレートに任せるほうが、シンプルで高速でおすすめです。

# 番外編:AppSyncにIP制限を設定する
AppSyncにIP制限をかけるには、AWS WAFを用いるのが最も簡単で確実かと思います。
ちなみにAppSyncとWAFの組み合わせは相性良く設計されていますので、他にもセキュリティに関する設定がいろいろできますので、ぜひやってみてください。

```ts
import * as cdk from '@aws-cdk/core';
import * as ddb from '@aws-cdk/aws-dynamodb';
import * as cognito from '@aws-cdk/aws-cognito';
import * as appsync from '@aws-cdk/aws-appsync';
import * as waf from '@aws-cdk/aws-wafv2';

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

    ...

    // WAFのIP setを作成する
    const wafIPSet = new waf.CfnIPSet(this, 'WafIPSet', {
      ipAddressVersion: 'IPV4',
      scope: 'REGIONAL',
      addresses: ['xx.xx.xx.xx/32'],
    });

    // AppSync用のWeb ACLを作成する
    // デフォルトですべてのアクションをブロックし、上記IP setルールのみ許可します
    const webAcl = new waf.CfnWebACL(this, 'WafWebAcl', {
      defaultAction: { block: {} },
      scope: 'REGIONAL',
      visibilityConfig: {
        cloudWatchMetricsEnabled: true,
        sampledRequestsEnabled: true,
        metricName: 'WafWebAcl',
      },
      rules: [
        {
          priority: 1,
          name: 'WafWebAclIPSetRule',
          action: { allow: {} },
          statement: {
            ipSetReferenceStatement: {
              arn: wafIPSet.attrArn,
            },
          },
          visibilityConfig: {
            sampledRequestsEnabled: true,
            cloudWatchMetricsEnabled: true,
            metricName: 'WafWebAclIPSetRule',
          },
        },
      ],
    });

    // AppSync APIにWAFを割り当てます
    const association = new waf.CfnWebACLAssociation(this, 'WebAclAssociation', {
      resourceArn: api.arn,
      webAclArn: webAcl.attrArn,
    });
  }
}
```

# おわりに
今回は、CDKでAppSyncを利用するごく簡単な例を紹介させていただきました。
AppSyncを触るにあたっての参考になれば幸いです。
今度は、AppSyncにおけるマッピングテンプレートでのバリデーションの話もできればなと思います。