既存DynamoDBをAmplifyで使うメモ

別のAmplifyプロジェクトで作成したDynamoDBのテーブルなど、既存のDynamoDBテーブルをAmplifyから使用する備忘録。（ここで記載しているほぼ全ての技術に関して使い始めて日が浅いので、間違いなどありましたらご指摘お願いします。）

### 参考記事
この２つの記事でほとんどわかるが、いくつかハマったので別途メモる。

1. [Connecting Amplify AppSync to an imported DynamoDB Table](https://imjacobclark.medium.com/connecting-amplify-appsync-to-an-imported-dynamodb-table-a7bef68c0ee1)
2. [AWS AppSync + Amplify JavaScript + CustomResourcesで、既存のDynamoDBなどをDatasourceとしたリゾルバーを作成する](https://thinkami.hatenablog.com/entry/2019/07/15/201356)

ちなみに、本件に関する[Amplifyの公式ドキュメント](https://docs.amplify.aws/cli/storage/import#import-an-existing-dynamodb-table)は、2021/08/03現在、まるで役に立たなかった。


# 前提
* 別のAmplifyプロジェクトで構築したDynamoDBのテーブルに新規作成するAmplifyプロジェクトから直接アクセスする
* 影響しているかわからないが、とりあえず両方のAmplifyでユーザ認証不要にするため、apiのauthをAPI keyにしている
  （この状態だと、AmplifyのGraphQLスキーマ記述に`@auth`が使用できなくなっている）
* AmplifyやGraphQLの基礎はわかっている（[Amplify公式チュートリアル](https://docs.amplify.aws/start/q/integration/js)、[GraphQL基本](https://www.robinwieruch.de/graphql-tutorial)）
* ユニットリゾルバを使用する。参考記事2ではパイプラインリゾルバでの方法も説明されている

# 手順
手順はすてべ既存のテーブルにアクセスする側のAmplifyプロジェクトのもので`amplify init`済みとする。

## DynamoDBテーブルをインポート
**2021/08/05追記**：唯一公式ドキュメントに書いてあるこのステップだが、やらなくてもデータの取得はできた。何に必要になるのか今のところ不明。Amplify env毎に切り替えられないようなので、できればやりたくない。

* `amplify import storage`を実行する。
* `DynamoDB table - NoSQL Database`を選択
* テーブルを選択

```shell
MacBook-Pro.local:amplify-js-app% amplify import storage
? Please select from one of the below mentioned services: DynamoDB table - NoSQL Database
✔ Select the DynamoDB Table you want to import: · Todo-xxxxxxxxxxxxxxxxxx-dev

✅ DynamoDB Table 'Todo-xxxxxxxxxxxxxxxxxx-dev' was successfully imported.

Next steps:
- This resource can now be accessed from REST APIs (‘amplify add api’) and Functions (‘amplify add function’)
```

## スキーマを定義
既存のテーブルと同じスキーマを定義する（完全に同じでなくても良い）。
`amplify/backend/api/myapi/schema.graphql`のようにAmplify CLIが自動生成しているファイルを下記の要領でスキーマを記述する。
このファイルがまだない場合は、`amplify add api`を実行してGraphQLを選んでプロジェクトにAPIを追加する。

DynamoDBテーブルを作成しないので`@model`ディレクティブは付けない。
要は、Amplifyが生成する`amplify/backend/api/myapi/build/schema.graphql`を自分で記述するわけなので、既存のAmplifyプロジェクトでAmplifyが作成したそのファイルが参考になる。

```graphql
type Query {
  listTodos(limit: Int, nextToken: String): TodoList
}

type Todo {
  id: ID!
  description: String!
}

type TodoList {
  items: [Todo]
}
```

ここではリストを取得するQueryを定義しているが、ページ上部で紹介している参考記事1では、インデックスを使って特定のアイテムを取得する方法を紹介している。

## リゾルバを定義
参考記事1, 2ではシンプルなリゾルバ定義を使用しているが、本記事ではシンプルなモデルを`@model`で定義したときにAmplifyがbuildディレクトリに作成する.req.vtlファイルの中身をまんまコピーする。（若干不要な部分はあるかもしれない・・・）
難しそうに見えるが、最終的には[JSONでAppSyncのリゾルバーテンプレート](https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/resolver-mapping-template-reference-overview.html)を出力すれば良く、単純なものだとただのJSONになる。
下記の例では、Velocityという言語で入力パラメータに基づいてJSONを作成している。良く見てみると大して難しくない。


```velocity:amplify/backend/api/myapi/resolvers/Query.listTodos.req.vtl
#set( $limit = $util.defaultIfNull($context.args.limit, 100) )
#set( $ListRequest = {
  "version": "2018-05-29",
  "limit": $limit
} )
#if( $context.args.nextToken )
  #set( $ListRequest.nextToken = $context.args.nextToken )
#end
#if( $context.args.filter )
  #set( $ListRequest.filter = $util.parseJson("$util.transform.toDynamoDBFilterExpression($ctx.args.filter)") )
#end
#if( !$util.isNull($modelQueryExpression)
    && !$util.isNullOrEmpty($modelQueryExpression.expression) )
  $util.qr($ListRequest.put("operation", "Query"))
  $util.qr($ListRequest.put("query", $modelQueryExpression))
  #if( !$util.isNull($ctx.args.sortDirection) && $ctx.args.sortDirection == "DESC" )
    #set( $ListRequest.scanIndexForward = false )
  #else
    #set( $ListRequest.scanIndexForward = true )
  #end
#else
  $util.qr($ListRequest.put("operation", "Scan"))
#end
$util.toJson($ListRequest)
```

```js:amplify/backend/api/myapi/resolvers/Query.listTodos.res.vtl
$util.toJson($ctx.result)
```

自分でリゾルバテンプレートを記述する際は下記あたりが参考になる。
* [AppSync公式プログラミングガイド](https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/resolver-mapping-template-reference-programming-guide.html)
* [DynamoDBのリゾルバーのマッピングテンプレートリファレンス](https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/resolver-mapping-template-reference-dynamodb.html)


## CloudFormationテンプレートでリソースを定義

### 環境毎の変数を準備
もし、接続先のDynamoDBが別のAmplifyプロジェクトで作成されたものならば、テーブル名は
`{任意のテーブル名}-{AppSyncのAPI ID}-{Amplify環境名}` となっているはずだ。
「Amplify環境名」は利用元のAmplify環境を同じ名前にしてしまえば`${env}`で対応できるが、「AppSyncのAPI ID」は設定で指定するしかない。

下記の様に使用する環境の設定に、`categories.api.{API名}.{任意の変数名}`を追加する。
ここで指定する `OtherAppSyncApiId` は実際に参照したいテーブル名を見れば確認できるし、AppSyncのコンソールでも確認できる。

```js:amplify/team-provider-info.json
{
  "devkanji": {
    "awscloudformation": {
      ...
    },
    "categories": {
      "api": {
        "myapi": {
          "OtherAppSyncApiId": "xxxxxxxxxxxxxxxxxxxxxxxxxxx"
        }
      }
    }
  }
}
```


[この情報のソース](https://github.com/aws-amplify/amplify-cli/issues/3240#issuecomment-577832717)

これで準備ができたので `amplify/backend/api/myapi/stacks/CustomResource.json` に下記を追記する。

### DyanmoDBアクセス用Roleの追加
下記のように`Resources`の下に`Role`を追加する。
参考記事2では既存のRoleを使用しているが、ここでは自動的に作成されるようにする。

```js:amplify/backend/api/myapi/stacks/CustomResource.json
{
  "Resources": {
    ...
    "DynamoDBDataSourceRole": {
      "Type": "AWS::IAM::Role",
      "Properties": {
        "RoleName": "DynamoDBDataSource",
        "AssumeRolePolicyDocument": {
          "Version": "2012-10-17",
          "Statement": [
            {
              "Effect": "Allow",
              "Principal": {
                "Service": "appsync.amazonaws.com"
              },
              "Action": "sts:AssumeRole"
            }
          ]
        },
        "Policies": [
          {
            "PolicyName": "DynamoDBFullAccess",
            "PolicyDocument": {
              "Version": "2012-10-17",
              "Statement": [
                {
                  "Effect": "Allow",
                  "Action": [
                    "dynamodb:*"
                  ],
                  "Resource": "arn:aws:dynamodb:ap-northeast-1:000000000000:table/Todo-xxxxxxxxxxxxxxxx-dev",
                }
              ]
            }
          }
        ]
      }
    },
    ...
  }
}
```

ここでは、`Policies`の中の`Resource`にアクセスしたいテーブルのARNを設定する。

上記例では固定値を使用したが、実際には環境ごとに自動的に切り替わるように[Fn::Sub](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-sub.html)で、変数を利用する。
前のステップで準備した変数`OtherAppSyncId`はここで利用する。

まず、CustomResource.jsonファイルにこのパラメータを使用する宣言をする。

```js:amplify/backend/api/myapi/stacks/CustomResource.json
{
  ...
  "Parameters": {
    "AppSyncApiId": { // これは元々あるが、このAmplifyが作っているAppSyncのAPI ID
      "Type": "String",
      "Description": "The id of the AppSync API associated with this project."
    },
    ...
    "OtherAppSyncApiId": { // これを追加する
      "Type": "String",
      "Description": "The AppSync API ID which is different for each environment"
    }
  },
  "Resources": {
    ...
  }
}
```

そして下記のように`Resource`の部分で変数を使用する。`${AWS::Region}` `${AWS::AccountId}` `${env}`は既に定義されているのですぐに使える。

```js
"Resource": [
  {
    "Fn::Sub": [
      "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/Todo-${OtherAppSyncApiId}-${env}",
      {
        "env": {
          "Ref": "env"
        },
        "OtherAppSyncApiId": {
          "Ref": "OtherAppSyncApiId"
        }
      }
    ]
  }
]
```

**追記**
`RoleName` が同じ名前のが存在するとエラーになるので、`"RoleName":`の１行も下記に置き換えて環境名でサフィックスします。

```js
"RoleName": {
  "Fn::Sub": [
    "DynamoDBDataSource-${env}",
    {
      "env": {
        "Ref": "env"
      }
    }
  ]
},
```

### DataSourceの追加
下記のように`Resources`の下に`DataSource`を追加して、どのテーブルにアクセスするかを定義する。

```js
{
  "Resources": {
    ...
    "DynamoDBDataSource": {
      "Type": "AWS::AppSync::DataSource",
      "Properties": {
        "ApiId": {
          "Ref": "AppSyncApiId"
        },
        "Name": "TodoDataset", // 任意の名称
        "Type": "AMAZON_DYNAMODB",
        "ServiceRoleArn": {
          "Fn::GetAtt": [
            "DynamoDBDataSourceRole",
            "Arn"
          ]
        },
        "DynamoDBConfig": {
          "TableName": "Todo-xxxxxxxxxxxxxxxx-dev",
          "AwsRegion": "ap-northeast-1"
        }
      }
    },
    ...
  }
}
```

ここでは`DynamoDBConfig.TableName`でアクセスするテーブル名を指定している。
このテーブル名はAmplifyが`import storage`で付けた名前ではなく実際のDynamoDBでの名前になる。


また、ここでも`TableName`と`AwsRegion`に変数を利用できる。

```js
"TableName": {
  "Fn::Sub": [
    "Todo-${OtherAppSyncApiId}-${env}",
    {
      "env": {
        "Ref": "env"
      },
      "OtherAppSyncApiId": {
        "Ref": "OtherAppSyncApiId"
      }
    }
  ]
},
"AwsRegion": { "Fn::Sub": "${AWS::Region}" }
```

### リゾルバを登録
下記のように`Resources`の下に`Resolver`を追加して、先に作成したリゾルバーを登録する。

* `DataSourceName`で先に定義したDataSourceを指定
* `FieldName`でschema.graphqlのQueryで定義した名前を指定
* `RequestMappingTemplateS3Location`と`ResponseMappingTemplateS3Location`でそれぞれのリゾルバファイル名を指定

```js
{
  "Resources": {
    ...
    "QueryListTodosResolver": {
      "Type": "AWS::AppSync::Resolver",
      "Properties": {
        "ApiId": {
          "Ref": "AppSyncApiId"
        },
        "DataSourceName": { // 参考記事1の書き方だとうまく行かなかったとこ
          "Fn::GetAtt": [
            "DynamoDBDataSource",
            "Name"
          ]
        },
        "TypeName": "Query",
        "FieldName": "listTodos", // schema.graphqlのQueryで定義した名前
        "RequestMappingTemplateS3Location": {
          "Fn::Sub": [
            "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/Query.listTodos.req.vtl",
            {
              "S3DeploymentBucket": {
                "Ref": "S3DeploymentBucket"
              },
              "S3DeploymentRootKey": {
                "Ref": "S3DeploymentRootKey"
              }
            }
          ]
        },
        "ResponseMappingTemplateS3Location": {
          "Fn::Sub": [
            "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/Query.listTodos.res.vtl",
            {
              "S3DeploymentBucket": {
                "Ref": "S3DeploymentBucket"
              },
              "S3DeploymentRootKey": {
                "Ref": "S3DeploymentRootKey"
              }
            }
          ]
        }
      }
    }
    ...
  }
}
```

## 環境に反映
`amplify push -y`で環境に反映する。
オプションを変更したい場合は`-y`を取ってっ実行。


## 動作確認
AppSyncコンソールのQueriesから確認

入力

```
query MyQuery {
  listTodos(limit: 3) {
    items {
      description
      id
    }
  }
}
```

結果

```js
{
  "data": {
    "listTodos": {
      "items": [
        {
          "description": "洗濯をする",
          "id": "8d24bed5-923a-4f5f-b37c-b15e5bc3b1e1"
        },
        {
          "description": "納豆を買いに行く",
          "id": "d17ffdca-8fd9-43e1-80cd-55a5a2b56b39"
        },
        {
          "description": "猫に餌をあげる",
          "id": "d9a4a26f-762f-4bee-928e-140a0b1b3819"
        }
      ]
    }
  }
}
```

以上。

## 解決していない課題
* 双方または片方に認証を入れた場合の扱い
* データ作成者のユーザIDを既存テーブルにどうやって入れるのか
* subscriptionで既存テーブルの更新を検知できるのか