Amplify CLI GraphQL TransformとディレクティブでAppSync+DynamoDBをいじってみよう！(@model @auth, @key)

# 概要

[AWS Amplify Advent Calendar 2019](https://qiita.com/advent-calendar/2019/aws-amplify)、7日目はAWS Amplify CLIでAWS AppSync + Amazon DynamoDBをセットアップする`schema.graphql`で使用可能な3つのディレクティブ(`@model`、`@auth`、 `@key`)を触っていきます。

## 想定読者
`amplify add api`、なんとなく使ったことあるけど、`@model`、`@auth`とか`@key`よくわからん！という方に向けた記事になります。検証用アプリケーションはReactでの実装ですが、Reactの知識がなくても大丈夫です。

## 動作確認環境
- @aws-amplify/cli 4.5.0
- aws-amplify 2.2.0
- aws-amplify-react 3.1.1
- @aws-amplify/api 2.1.1

# 下準備
## React App

```
$ npx create-react-app amplify-react
$ cd amplify-react
$ npm start
```

ブラウザが立ち上がり、おなじみのスタートページが現れます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532688/c26dd242-046a-818d-8ff1-59de8b19fd65.png)


## Amplifyの初期設定
`amplify configure`は一度実行している想定です。(`amplify configure`がまだの方は[コチラ]())
${profile name}にはご自身が使用されているAmplify用の[Profile名](https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/cli-configure-profiles.html)を入力してください。

```
amplify init
? Enter a name for the project amplify-react
? Enter a name for the environment dev
? Choose your default editor: Vim (via Terminal, Mac OS only)
? Choose the type of app that you're building javascript
Please tell us about your project
? What javascript framework are you using react
? Source Directory Path:  src
? Distribution Directory Path: build
? Build Command:  npm run-script build
? Start Command: npm run-script start
Using default provider  awscloudformation

For more information on AWS Profiles, see:
https://docs.aws.amazon.com/cli/latest/userguide/cli-multiple-profiles.html

? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use ${profile name}
```

Amplifyのセットアップが終了しました！

## GraphQL APIの作成
AmplifyではapiとしてREST APIとGraphQLが選択できます。REST APIを選択するとAPI Gateway + LambdaによるREST APIの雛形が、GraphQLを選択するとAppSync + DynamoDBの雛形が作成されます。今回はGraphQLをいじりたいので、ServiceでGraphQLを選択します。

```
$ amplify add api
? Please select from one of the below mentioned services: GraphQL
? Provide API name: amplifyreact
? Choose the default authorization type for the API Amazon Cognito User Pool
Using service: Cognito, provided by: awscloudformation

 The current configured provider is Amazon Cognito.

 Do you want to use the default authentication and security configuration? Default configuration
 Warning: you will not be able to edit these selections.
 How do you want users to be able to sign in? Username
 Do you want to configure advanced settings? No, I am done.
Successfully added auth resource
? Do you want to configure advanced settings for the GraphQL API No, I am done.
? Do you have an annotated GraphQL schema? No
? Do you want a guided schema creation? No
? Provide a custom type name MyType 
```

default authorization typeに`Amazon Cognito User Pool`を選択すると、同時にAuthカテゴリのリソースも作成されます。`amplify status`で確認してみましょう。

```
$ amplify status
Current Environment: dev
| Category | Resource name        | Operation | Provider plugin   |
| -------- | -------------------- | --------- | ----------------- |
| Auth     | amplifyreactbc0d7af4 | Create    | awscloudformation |
| Api      | amplifyreact         | Create    | awscloudformation |
```

Auth、Apiがそれぞれ作成されたことを確認できました。

# `@model`
## `@model`とは？
[公式ドキュメント](https://aws-amplify.github.io/docs/cli-toolchain/graphql#generates)によれば、`@model`を使用することで、以下のAWSリソースを設定することができます。

- デフォルトでPAY_PER_REQUEST請求モードのAmazon DynamoDB
- schema.graphqlで設定した通りにアクセス可能なAWS AppSync DataSource
- AWS AppSyncがAmazon DynamoDBを呼び出すのに必要なIAM Role
- 最大8つのresolver(create, update, delete, get, list, onCreate, onUpdate, onDelete)
- mutation(create, update, delete)に使用するInput objects
- list系のqueriyと`@connection`で使用可能な、Filter input objects

`schema.graphql`での非常に少ない記述量で、これだけのリソース設定を終えてしまうことができるのはAmplifyの魅力の一つでしょう。

## Todo typeの作成


`amplify/backend/api/amplifyreact/schema.graphql`を開くと、次のような中身になっています。今回は全て削除してしまいましょう。

```
type MyType @model {
    id: ID!
    title: String!
    content: String!
    price: Int
    rating: Float
}
```

そして次のようなTodoモデルを定義します。

```
type Todo 
    @model
{
	id: ID! 
	name: String!
	description: String!
	updatedAt: AWSDateTime 
	createdAt: AWSDateTime 
}
```



ここで一旦、amplify pushをして変更を反映しましょう。
色々聞かれますが、すべて何も入力せずEnterを押して、デフォルトの選択肢で進めます。

```
$ amplify push
✔ Successfully pulled backend environment dev from the cloud.

Current Environment: dev

| Category | Resource name        | Operation | Provider plugin   |
| -------- | -------------------- | --------- | ----------------- |
| Auth     | amplifyreactbc0d7af4 | Create    | awscloudformation |
| Api      | amplifyreact         | Create    | awscloudformation |
? Are you sure you want to continue? Yes

The following types do not have '@auth' enabled. Consider using @auth with @model
     - Todo
Learn more about @auth here: https://aws-amplify.github.io/docs/cli-toolchain/graphql#auth


GraphQL schema compiled successfully.

Edit your schema at /Users/daisnaga/Dev/tmp/amplify-react/amplify/backend/api/amplifyreact/schema.graphql or place .graphql files in a directory at /Users/daisnaga/Dev/tmp/amplify-react/amplify/backend/api/amplifyreact/schema
? Do you want to generate code for your newly created GraphQL API Yes
? Choose the code generation language target javascript
? Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.js
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested] 2

----
a lot of logs...
----

UPDATE_COMPLETE amplify-amplify-react-dev-XXXXXX AWS::CloudFormation::Stack Fri Dec 06 2019 21:36:26 GMT+0900 (Japan Standard Time)
✔ Generated GraphQL operations successfully and saved at src/graphql
✔ All resources are updated in the cloud

GraphQL endpoint: https://XXXXXXXX.appsync-api.us-west-2.amazonaws.com/graphql
```

たくさんのログが吐かれますが、UPDATE_COMPLETEと、GraphQLのendpointが表示されれば`amplify push`成功です。
AppSyncのManagement Consoleから、作成したリソースを確認しましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532688/a6bec05d-5a2d-4c96-fda8-2a8057ffef26.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532688/c9410d40-0868-11f1-eb18-21971c99d771.png)


同様に、Amazon DynamoDBに新しいTableが作成されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532688/87b9002f-efd8-a579-5e0a-30ffdd3e946c.png)

## 検証用Web Applicationの作成
サーバー側の準備が整ったので、Todoを追加、確認できるインタフェースを作成しましょう。

```
$ npm install --save aws-amplify @aws-amplify/api @aws-amplify/pubsub aws-amplify-react
$ vim src/App.js
```

適当なエディタで`src/App.js`を開いたら、中身を全部消して次のコードをコピペします。

```
import React, {useState, useEffect, useReducer } from 'react';

import Amplify, { Auth } from 'aws-amplify';
import API, { graphqlOperation } from '@aws-amplify/api';

import { withAuthenticator } from 'aws-amplify-react'

import { createTodo } from './graphql/mutations';
import { listTodos } from './graphql/queries';
import { onCreateTodo } from './graphql/subscriptions';

import awsconfig from './aws-exports';
import './App.css';

Amplify.configure(awsconfig);

const QUERY = 'QUERY';
const SUBSCRIPTION = 'SUBSCRIPTION';

const initialState = {
  todos: [],
};

const reducer = (state, action) => {
  switch (action.type) {
    case QUERY:
      return {...state, todos: action.todos};
    case SUBSCRIPTION:
      return {...state, todos:[...state.todos, action.todo]}
    default:
      return state;
  }
};

async function createNewTodo() {
  const todo = { name:  "Todo " + Math.floor(Math.random() * 10) };
  await API.graphql(graphqlOperation(createTodo, { input: todo }));
}

function signOut(){
  Auth.signOut()
  .then(data => console.log(data))
  .catch(err => console.log(err));
}

function App() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const [user, setUser] = useState(null);

  useEffect(() => {

    async function getUser(){
      const user = await Auth.currentUserInfo();
      setUser(user);
      return user
    }

    getUser();

    async function getData() {
      const todoData = await API.graphql(graphqlOperation(listTodos));
      dispatch({ type: QUERY, todos: todoData.data.listTodos.items });
    }

    getData();


    const subscription = API.graphql(graphqlOperation(onCreateTodo)).subscribe({
      next: (eventData) => {
        const todo = eventData.value.data.onCreateTodo;
        dispatch({ type: SUBSCRIPTION, todo });
      }
    });

    return () => subscription.unsubscribe();
  }, []);

  return (
    <div className="App">
      <p>user: {user!= null && user.username}</p>
      <button onClick={signOut}>Sign out</button>
      <button onClick={createNewTodo}>Add Todo</button>
      <div>
        {state.todos.length > 0 ? 
          state.todos.map((todo) => <p key={todo.id}>{todo.name} ({todo.createdAt})</p>) :
          <p>Add some todos!</p> 
        }
      </div>
    </div>
  );
}

export default withAuthenticator(App, {
  signUpConfig: {
    hiddenDefaults: ['phone_number']
  }
});
```

これで下準備が整いました！`@auth`と`@key`をみていきましょう。

# `@auth`
本セクションでは、`@auth`を用いて、ユーザー自身が作成したTodoしか一覧に表示されないようにしてみます。

## `@auth`とは？
>
Authorization is required for applications to interact with your GraphQL API. API Keys are best used for public APIs (or parts of your schema which you wish to be public) or prototyping, and you must specify the expiration time before deploying. IAM authorization uses Signature Version 4 to make request with policies attached to Roles. OIDC tokens provided by Amazon Cognito User Pools or 3rd party OpenID Connect providers can also be used for authorization, and simply enabling this provides a simple access control requiring users to authenticate to be granted top level access to API actions. You can set finer grained access controls using @auth on your schema which leverages authorization metadata provided as part of these tokens or set on the database items themselves.
>
@auth object types that are annotated with @auth are protected by a set of authorization rules giving you additional controls than the top level authorization on an API. You may use the @auth directive on object type definitions and field definitions in your project’s schema.
>
When using the @auth directive on object type definitions that are also annotated with @model, all resolvers that return objects of that type will be protected. When using the @auth directive on a field definition, a resolver will be added to the field that authorize access based on attributes found the parent type.
>
[https://aws-amplify.github.io/docs/cli-toolchain/graphql#auth](https://aws-amplify.github.io/docs/cli-toolchain/graphql#auth) より

要約すると、

- Amazon Cognito User PoolやサードパーティのOIDCプロバイダによって認証(Authentication)されたユーザーに対し、ユーザーの認証メタデータを使用してGraphQL APIのアクションに対する認可(Authorization)のルールを設定することが可能です。
- `@auth`を使用することでAPIの認可をトップレベルで行うことが可能です
- `@auth`と`@model`がついたtypeのObjectをreturnする全てのGraphQL resolverは、設定したAuthorization Ruleによって保護されます。

## `@auth`をTodoに追加
自分が作成したitemのみを表示できるよう`@auth`で認可ルールを書いてみましょう。
`amplify/backend/api/amplifyreact/schema.graphql`を開いて以下のように書き換えます。

```
type Todo 
    @model 
    @auth(rules: [{allow: owner}])
{
    id: ID! 
    name: String!
    owner: String
    description: String!
    updatedAt: AWSDateTime 
    createdAt: AWSDateTime 
}
```

`amplify push`して、ここまでの変更をAWSのリソースに反映しましょう。

```
$ amplify push
```

## Reactのアップデート
Developer Toolでみると、以下のようなエラーがAppSyncから返却されています。

```
"Connection failed: {"errors":[{"message":"Variable 'owner' has coerced Null value for NonNull type 'String!'"}]}"
```

`@auth`を指定したことにより、Subscriptionの際にownerフィールドを指定する必要が生じたためです。
`/src/App.js`を一部変更しましょう。[-]のコードを消し、同じ場所に[+]を追加してください。

```
[-]
const subscription = API.graphql(graphqlOperation(onCreateTodo)).subscribe({
  next: (eventData) => {
    const todo = eventData.value.data.onCreateTodo;
    dispatch({ type: SUBSCRIPTION, todo });
  }
});

[+]
let subscription;
getUser().then((user) => {
  subscription = API.graphql(graphqlOperation(onCreateTodo, {owner: user.username})).subscribe({
    next: (eventData) => {
      const todo = eventData.value.data.onCreateTodo;
      dispatch({ type: SUBSCRIPTION, todo });
    }
  });
});
```

## 動作確認
アプリケーションに戻ってみると、Todoが表示されていない状態になります。
これは、今まで作成したDynamoDBのitemに、`owner`フィールドがないため、`@auth`によって取得が禁止されているためです。

いくつかTodoを作成しておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532688/f7fa8846-544c-fa24-a273-1b6ecf1b3633.png)


続いて、別のUserでサインインしてみましょう。一旦SignOutボタンをおしてサインアウトし、別のアカウントを作成してサインインします。別のブラウザを使ったり、ChromeのIncognitoモードを使用すると検証しやすいかもしれません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532688/d55f41f1-8889-d244-51f9-90dad935887d.png)

先ほどのアカウントで作成したTodoが一切表示されていないことがわかります。
Add Todoを押した上でDynamoDBをみてみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532688/7b3520c5-f53b-97db-c41c-16667ec735a1.png)

ownerフィールドにitemを作成したUserのusernameが入力されていることを確認できました。

## おまけ
公式ドキュメントによると、実は先ほどの書き方は以下の書き方と全く同じ内容です。

```
type Post
  @model
  @auth(
    rules: [
      {allow: owner, ownerField: "owner", operations: [create, update, delete, read]},
    ])
{
    id: ID! 
    name: String!
    description: String!
    updatedAt: AWSDateTime 
    createdAt: AWSDateTime 
　　 owner: String
}
```

- `ownerField`を省略すると`owner`
- `operations`を省略すると、`create, update, delete, read`

がそれぞれ設定されていたことがわかります。

では`allow`では何を指定しているのでしょうか？[Definition](https://aws-amplify.github.io/docs/cli-toolchain/graphql?sdk=js#definition-2)を見てみましょう。

```
# When applied to a type, augments the application with
# owner and group-based authorization rules.
directive @auth(rules: [AuthRule!]!) on OBJECT, FIELD_DEFINITION
input AuthRule {
  allow: AuthStrategy!
  provider: AuthProvider
  ownerField: String # defaults to "owner" when using owner auth
  identityClaim: String # defaults to "username" when using owner auth
  groupClaim: String # defaults to "cognito:groups" when using Group auth
  groups: [String]  # Required when using Static Group auth
  groupsField: String # defaults to "groups" when using Dynamic Group auth
  operations: [ModelOperation] # Required for finer control

  # The following arguments are deprecated. It is encouraged to use the 'operations' argument.
  queries: [ModelQuery]
  mutations: [ModelMutation]
}
enum AuthStrategy { owner groups private public }
enum AuthProvider { apiKey iam oidc userPools }
enum ModelOperation { create update delete read }

# The following objects are deprecated. It is encouraged to use ModelOperations.
enum ModelQuery { get list }
enum ModelMutation { create update delete }
```

`allow`ではAuthStrategy、つまり認可戦略を指定しています。認可戦略には次の4つがあります。

- `owner`: itemの作成者をownerとし、ownerに関する認可を指定する
- `groups`: itemの作成者が所属するgroupに対し、groupに関する認可を指定する
- `private`: 認証済みユーザー全体に対して認可を指定する
- `public`: 認証していないユーザー全体に対して認可を指定する

そして、`ownerField`では`owner`の認可戦略を使う時に使用するフィールドを指定することができる、というわけです。`@auth`は一つのtypeに対し複数指定することが可能ですから、かなり柔軟に認可のロジックを作成することができそうです。

# `@key`
本セクションでは@keyを用いて、Todoの`name`でソートして一覧を表示してみましょう。

##`@key`とは？

>
Amazon DynamoDB is a key-value and document database that delivers single-digit millisecond performance at any scale but making it work for your access patterns requires a bit of forethought. __DynamoDB query operations may use at most two attributes to efficiently query data.__ The first query argument passed to a query (the hash key) must use strict equality and the second attribute (the sort key) may use gt, ge, lt, le, eq, beginsWith, and between. DynamoDB can effectively implement a wide variety of access patterns that are powerful enough for the majority of applications.
[公式ドキュメント](https://aws-amplify.github.io/docs/cli-toolchain/graphql?sdk=js#key)より

一番大事なところは太字の箇所です。DynamoDBのクエリは、最大で二つのAttributeを使うのがよいとされます。この二つのAttributeはPartition Key(PK)とSort Key(SK)と呼ばれます。[DynamoDBのBlack Belt資料](https://www.slideshare.net/AmazonWebServicesJapan/20170809-black-belt-dynamodb)によると、DynamoDBはPK単体、あるいはPKとSKを組み合わせてプライマリキーとして利用することができます。

>
- Partition Table
    - Partition Key単体でプライマリキーとして利用
    - 順序を指定しないハッシュインデックスを構築するためのキー
    - テーブルは、性能を確保するために分割(パーティショニング)される場合あり
- Partition-Sort Table
    - Partation + Sortでプライマリキーとすることもできる
    - 同一のPartation Keyでのデータの並びを保証するためにSort Keyが使われる
    - Partition Keyの数に上限はない（Local Secondary Indexesを使用時はデータサイズ上限あり）


`@key`の主な役割は、このPKとSKを指定することにあります。PKとSKを使わずにクエリを書く場合、DynamoDBのTableの中身全てをスキャンすることになり、非常に効率が悪いです。効率が悪いと、クエリに時間がかかるだけでなく、従量課金制であるためコストもかさんでしまいます。(そもそもAmplifyの`schema.graphql`でセットアップしたQueryでは、`@key`で指定したフィールド以外をinputにできません。全文検索を行いたい場合などは[@searchable](https://aws-amplify.github.io/docs/cli-toolchain/graphql?sdk=js#searchable)を使用してElasticSearchをリソースとしたResolverを利用するのが良いでしょう。)
そのため、本来はどのようなKeyで各itemを取り出すかアプリケーション全体のアーキテクティングを行い、逆算してPK、SKを設計することが肝心です。このあたりは一見面倒にも思えますが、適切に設計することでスケールした際のクエリ発行時のレイテンシを短くすることにもつながります。具体的なDynamoDBのデザインパターンについては[こちら](https://www.slideshare.net/AmazonWebServicesJapan/aws-black-belt-online-seminar-2018-amazon-dynamodb-advanced-design-pattern)をご参照ください。

では、`@key`を設定しないまま作成したDynamoDB Tableは何をPK、SKとしているのでしょうか？

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532688/648f24ea-d575-a917-7d79-5c8681eb6274.png)

確認してみると、PKとして`id`、SKには何も設定されていないことがわかります。

## GraphQL リクエスト返却時にソートして返す
一度作成したDynamoDB TableのPK、SKを変更することはできないため、Tableを作り直すことになってしまいます。今回はDynamoDBのGlobal Secondary Index(GSI)を使用して`description`フィールドを用いたSort機能を実装します。

>
アプリケーションによっては、さまざまな属性をクエリ基準に使用して、いろいろな種類のクエリを実行する必要があります。このような要件に対応するために、1 つ以上のグローバルセカンダリインデックスを作成して、Amazon DynamoDB でそのインデックスに対して Query リクエストを発行できます。
[グローバルセカンダリインデックス](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/GSI.html)より

GSIの詳細な説明は省かせていただきますが、__ざっくり__いうとPKとSKを変えたTableをもう一つ作成し、スキャンを回避して高速に特定のクエリを実行するための機能です。

今回は、新たに`owner`をPK、`name`をSKとしたGSIを作成します。

```
type Todo 
	@model 
	@auth(rules: [{allow: owner}])
	@key(name: "SortByName", fields:["owner", "name"], queryField: "listTodosSortedByName" )
{
	id: ID! 
	name: String!
	owner: String
	updatedAt: AWSDateTime 
	createdAt: AWSDateTime 
}
```

- `name`はGSIの名前です
- `fields`の配列のうち、一つ目がPK、二つ目がSKになります
- `queryField`は、このGSIを用いてqueryする時に使用するresolverの名前です
- 明示的に`owner`フィールドを書かないと@keyで指定できないため、`owner`フィールドを足しています

では、このResolverを利用するようにApp.jsに変更を加えましょう。[-]のコードを消し、同じ場所に[+]を追加してください。

```
[-] 
import { listTodos} from './graphql/queries';

[+] 
import { listTodos, listTodosSortedByName } from './graphql/queries';
```

```
[-]
async function getData() {
    const todoData = await API.graphql(graphqlOperation(listTods));
    dispatch({ type: QUERY, todos: todoData.data.listTodos.items });
}
getData();

[+]
async function getData(user) {
    const todoData = await API.graphql(graphqlOperation(listTodosSortedByName, {owner: user.username}));
    dispatch({ type: QUERY, todos: todoData.data.listTodosSortedByName.items });
}
getUser().then((user) => getData(user));

```

## 動作確認
変更が完了したらアプリケーションに戻ってみましょう！作成順に表示されていたTodoが、Todoの`name`の昇順に変更されていることが確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532688/b81651db-5f7c-a44f-e5b8-41f1b3779194.png)

この例では件数が少ないので、`@key`を使わずReact側でソートしても大差ないように感じるかもしれません。実際に件数が非常に多くフロント側で処理するのが辛い場合や、NextTokenを使用してページネーションを実現する際に効力を発揮します。


# まとめ
- `@model`はAppSyncとDynamoDBのリソースを準備するよーという宣言です。
- `@auth`を使い、AppSyncのResolverに対するアクセス権限の設定を行うことが可能です。
- `@key`を使うことで、任意のフィールドを使用したQuery Resolverを作成することができます。

最後まで読んでいただいてありがとうございました！
AWS Amplify Advent Calendar、明日は @FumihikoSHIROYAMA さんです！