AWS AppSync + Cognito + Amplify + ApolloでSPAを作ってみた話&同じ構成で簡易版Todoアプリをホストするまでの手順

#　概要
AppSync + Cognito + Amplify + Apolloの使い方を学ぶためにReactでTodoアプリを作ってみたので、
それぞれの簡単な説明と作成までの手順などを残しておきます。

#### 作成したもの：
※DEV(http)なのでもしサインアップする場合普段使っているパスワードは使わないでください
http://appsync-apollo-todo-20190503160622-hostingbucket-dev.s3-website-ap-northeast-1.amazonaws.com/
https://github.com/yuuyu00/AppSync-TodoList

# AWS AppSyncとは
AWS AppSyncはGraphQLによるマネージドサービスです。Subscription(WebSocket上でサーバ側からデータを送信することでポーリングなしでリアルタイムにデータを取得する機能）を活かした複数端末（環境）での同期が特徴です。
https://aws.amazon.com/jp/appsync/

# AWS Amplifyとは
AWS AmplifyはAWSのマネージドサービスを使って簡単にSPAやネイティブアプリの構築をできるようにするライブラリです。
Amplify CLIで設定するだけでAPIやDBやCognito UserPoolなどを自動で作成・設定してくれたり、クライアント側のconfigを自動生成してくれたりします。
今回はこれを使ってアプリを作っていきます。
https://aws-amplify.github.io/

# Apollo Clientとは
Apollo Clientは今のところ一番人気（らしい）GraphQLクライアントライブラリです。
ApolloはGraphQLのqueryやmutationを簡単に扱うのが役目ではあるのですが、それらを実行した結果のデータをただ仲介するのではなく、
Apollo Storeとよばれるキャッシュにデータを保存して、そこからコンポーネントに渡す仕組みになっています。
図で表すとこんなイメージです。
<img width="901" alt="スクリーンショット 2019-05-04 17.41.29.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258226/bf60112c-e424-ffb0-2f53-d9ca540c8cb3.png">

## Mutation
単一のデータに対してMutationを行った場合、データに一意な属性があれば何もしなくてもキャッシュが更新されます。
キャッシュが更新されるということは、当然キャッシュを利用して表示しているUIも自動的に更新されます。
ただし、追加や削除は別の方法でキャッシュを更新する必要があります。

### Refetch
その名の通り再度データをフェッチします。
<img width="921" alt="スクリーンショット 2019-05-04 17.57.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258226/81f3a5a2-f4ec-456b-7e04-a780950d18fe.png">
最もシンプルで簡単な方法ですが、リクエストが２回発生して効率が良くない上に時間もかかります。

### Update
MutationのResponseを利用してキャッシュを直接書き換える方法です。
更新に必要なデータを返すようにMutationを定義しておく必要があります。
<img width="1204" alt="スクリーンショット 2019-05-04 18.09.27.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258226/5871d385-62f7-5913-d7fb-16edab1f7a9e.png">
リクエストが一回で済むので効率が良く、時間もRefetchほどかかりません。
ですが、Apolloでは一回分のリクエストの時間すら省くことができます。

## Optimistic UI
Apollo Clientの強力な機能の一つにOptimistic UIがあります。
これはMutationを行ったタイミングで、予想される擬似的なResponseを返すことで、即座にキャッシュを更新することができるものです。
Optimistic UIによるキャッシュ更新を行った場合でも、本来のResponseが到着し次第そのResponseの正しい値によってUpdateされます。
<img width="1276" alt="スクリーンショット 2019-05-04 18.58.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258226/57d15ba2-f9d4-40b6-349c-fa148ff62968.png">
日本語の情報が少なかったことと、ググり力・英語力・公式ドキュメント読む力が低いせいでこの辺りの理解に少し苦労しました。
今回のTodoアプリではタスクとカテゴリーのCRUD、タスクのdone/undoneすべてにOptimistic UIを用いたことで、初回ロード時以外ロードしているように見えない実装にすることができました。

## Updateでハマった点
```js
      // storeのキャッシュとMutationのレスポンスを受け取る
      update: (store, { data: { createTodo } }) => {

        // Queryごとに保存されているのでQueryを指定してキャッシュからリストを取り出す
        const data = store.readQuery({
          query: gql(listTodos),
          variables: { limit: 100 },
        });

        // 取り出したリストに（Optimistic)Responseを追加する
        data.listTodos.items.push(createTodo);

        // リストをキャッシュに書き戻す
        store.writeQuery({
          query: gql(listTodos),
          variables: { limit: 100 },
          data,
        });
      }
```
実際のコードですが、キャッシュからQueryを指定してリストを取り出す際に変数もQueryに含まれる（変数ありなしは別扱いされる）ことに気づかずかなりハマりました。お気をつけください。

# 認証
Amplify CLIでアプリの認証方法にCognitoを選択すると、ユーザープールが作られすぐにサインアップ・サインインが可能になります。
最も簡単に認証をクライアントサイドで使用する方法は`aws-amplift-react`の`withAuthenticator`HOCでコンポーネントをラップすることです。

```jsx:App.jsx
import { withAuthenticator } from 'aws-amplify-react'

const App = () => {
  return <div>App<div>
}

export default withAuthenticator(App);
```
ほとんどの場合デフォルトのサインイン画面を使うことはないでしょうから、実際は`aws-amplify`の`Auth`APIを使うことになると思います。

# Amplifyで簡易版Todoアプリをホストする
実際にAmplifyでReactのアプリを作成してホストしていきます。

## Amplifyでサーバーサイドをセットアップする

Amplifyをグローバルインストールします。

```zsh
npm i -g @aws-amplify/cli
```

AWSアカウントに関する設定をします。
リージョンやIAMユーザーについて聞かれます。

```zsh
amplify configure
```

create-react-appしてライブラリ追加します。

```zsh

create-react-app hello-world
cd hello-world
npm i --save aws-amplify aws-amplify-react
```

AWSバックエンドのリソースを設定します。

```

amplify init

Note: It is recommended to run this command from the root of your app directory
# プロジェクト名
? Enter a name for the project hello-world

# 環境名
? Enter a name for the environment dev

# Amplify CLIから開かれるエディタ
? Choose your default editor: Visual Studio Code

# 言語
? Choose the type of app that you're building javascript
Please tell us about your project

# フレームワーク
? What javascript framework are you using react

# 以下ディレクトリ名、コマンド名　デフォルトでOK
? Source Directory Path:  src
? Distribution Directory Path: build
? Build Command:  npm run-script build
? Start Command: npm run-script start
```

GraphQLのAPIを追加します。

```
amplify add api
# GraphQLを選択
? Please select from one of the below mentioned services GraphQL

# API名
? Provide API name: simpletodo

# 認証方法：Cognitoを選択
? Choose an 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 when using your Cognito User Pool? Username
 Warning: you will not be able to edit these selections.

 # サインアップに必要な属性：Emailのみ選択
 What attributes are required for signing up? (Press <space> to select, <a> to toggle all, <i> to invert selection)Email
Successfully added auth resource

# スキーマファイルが既にあるか：Noを選択
? Do you have an annotated GraphQL schema? No

# ガイド付きでスキーマを作成するか：Yes
? Do you want a guided schema creation? Yes

# 最も合致する使い方：１番目を選択
? What best describes your project: Single object with fields (e.g., “Todo” with ID, name, description)

# 今すぐスキーマを編集するか：Yesを選択
? Do you want to edit the schema now? Yes
Please edit the file in your editor: /Users/yu/Amplify/hello-world/amplify/backend/api/simpletodo/schema.graphql
? Press enter to continue
```

エディタが開かれるので、スキーマを編集します。

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

@ authディレクティブで所有者のみがデータにアクセスできるルールを追加しました。
他にも@ connectionディレクティブでタイプ同士を相互に参照したりもできますが、今回はシンプルにIDとnameだけにします。
編集が完了したらターミナルに戻ってEnterするとAPIのクライアントサイドでの追加が完了します。

次にクライアントサイドでのAPIの設定をサーバーサイドに反映させて実際にAPIを作成します。

```
amplify push

# 続行する
? Are you sure you want to continue? Yes

# スキーマファイルのコンパイル完了　文法が何か間違っているとここでエラーになる
GraphQL schema compiled successfully.
Edit your schema at /Users/yu/Amplify/hello-world/amplify/backend/api/simpletodo/schema.graphql or place .graphql files in a directory at /Users/yu/Amplify/hello-world/amplify/backend/api/simpletodo/schema

# Query/Mutationのコードを生成するか：Yes
? Do you want to generate code for your newly created GraphQL API Yes

# 生成するコードの言語：javascript
? Choose the code generation language target javascript

# ファイル名のパターン：デフォルト
? Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.js

# 全てのGraphQLの操作に対してコードを生成するか：Yes
? 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
```

これでAPIが作成され呼び出しの準備が完了しました。
AppSyncのコンソールから、GraphiQLというGraphQLのIDEで自動生成されたドキュメントを確認したりQueryやMutationを実行したりできます。
<img width="530" alt="スクリーンショット 2019-05-04 21.09.49.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258226/df1efd8f-d1b7-3317-6b6f-de0046ef762a.png">



## クライアントサイドを実装する
必要なライブラリをインストールします。

```
npm i --save aws-appsync aws-appsync-react graphql-tag react-apollo react-apollo-hooks
```


Privider等を設定します。

```jsx:index.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import AWSAppSyncClient, { AUTH_TYPE } from 'aws-appsync';
import Amplify, { Auth } from 'aws-amplify';
import { ApolloProvider } from 'react-apollo-hooks';

import AppSyncConfig from './aws-exports';
import App from './App';

// config読み込み
Amplify.configure(AppSyncConfig);

// AppSyncとApollo Clientを接続する
const client = new AWSAppSyncClient({
  url: AppSyncConfig.aws_appsync_graphqlEndpoint,
  region: AppSyncConfig.aws_appsync_region,
  auth: {
    type: AUTH_TYPE.AMAZON_COGNITO_USER_POOLS,
    jwtToken: async () =>
      (await Auth.currentSession()).getIdToken().getJwtToken(),
  },
  disableOffline: true,
});

ReactDOM.render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>,
  document.getElementById('root'),
);

```

実際にCRUDしてTodoリストを表示する部分を実装します。

```jsx:App.jsx
import React, { useState } from 'react';
import gql from 'graphql-tag';
import { withAuthenticator } from 'aws-amplify-react';
import { useQuery, useMutation } from 'react-apollo-hooks';

import { listTodos } from './graphql/queries';
import {
  createTodo as createTodoMutation,
  updateTodo as updateTodoMutation,
  deleteTodo as deleteTodoMutation,
} from './graphql/mutations';

const App = () => {
  const [todoName, setTodoName] = useState('');
  const [updateTodoId, setUpdateTodoId] = useState('');
  const [updateTodoName, setUpdateTodoName] = useState('');
  const { data, loading } = useQuery(gql(listTodos));

  const createTodo = useMutation(gql(createTodoMutation));
  const updateTodo = useMutation(gql(updateTodoMutation));
  const deleteTodo = useMutation(gql(deleteTodoMutation));

  const handleCreateTodo = () => {
    const input = {
      name: todoName,
    };

    createTodo({
      // 引数
      variables: { input },

      // 作成したTodoの引数を利用して擬似的なレスポンス
      optimisticResponse: {
        createTodo: {
          id: 'Loading...',
          name: input.name,
          __typename: 'Todo',
        },
      },

      // Mutationのレスポンスからキャッシュを更新
      update: (store, { data: { createTodo } }) => {
        const data = store.readQuery({
          query: gql(listTodos),
        });

        // キャッシュのitemsに作成したTodoを追加
        data.listTodos.items.push(createTodo);

        // 書き戻す
        store.writeQuery({
          query: gql(listTodos),
          data,
        });
      },
    });

    setTodoName('');
  };

  const handleUpdateTodo = () => {
    const input = {
      id: updateTodoId,
      name: updateTodoName,
    };

    updateTodo({
      // 引数
      variables: { input },

      // 作成したTodoの引数を利用して擬似的なレスポンス
      optimisticResponse: {
        updateTodo: {
          id: input.id,
          name: input.name,
          __typename: 'Todo',
        },
      },
    });

    setUpdateTodoId('');
    setUpdateTodoName('');
  };

  const handleDeleteTodo = id => {
    const input = {
      id,
    };

    deleteTodo({
      // 引数
      variables: { input },

      // 作成したTodoの引数を利用した擬似的なレスポンス
      optimisticResponse: {
        deleteTodo: {
          id: input.id,
          name: 'Loading...',
          __typename: 'Todo',
        },
      },

      // Mutationのレスポンスからキャッシュを更新
      update: (store, { data: { deleteTodo } }) => {
        let data = store.readQuery({
          query: gql(listTodos),
        });

        // キャッシュのitemsから削除したTodoを削除
        data.listTodos.items = data.listTodos.items.filter(
          todo => todo.id !== deleteTodo.id,
        );

        // 書き戻す
        store.writeQuery({
          query: gql(listTodos),
          data,
        });
      },
    });
  };

  const renderTodos = () => {
    return (
      <ul>
        {data.listTodos.items.map(todo => (
          <>
            <li key={todo.id}>{todo.name}</li>
            <button
              onClick={() => {
                setUpdateTodoId(todo.id);
                setUpdateTodoName(todo.name);
              }}
            >
              編集
            </button>
            <button onClick={() => handleDeleteTodo(todo.id)}>削除</button>
          </>
        ))}
      </ul>
    );
  };

  if (loading) return <div>Loading...</div>;
  return (
    <>
      <div>
        <input
          value={todoName}
          onChange={e => setTodoName(e.target.value)}
          type="text"
        />
        <button disabled={!todoName} onClick={handleCreateTodo}>
          追加
        </button>
      </div>
      <div>
        <input
          value={updateTodoName}
          onChange={e => setUpdateTodoName(e.target.value)}
          type="text"
        />
        <button onClick={handleUpdateTodo} disabled={!updateTodoId}>
          更新
        </button>
      </div>
      {renderTodos()}
    </>
  );
};

export default withAuthenticator(App);

```
1ファイルにしたので長くなってしまいましたが、以上がOptimistic UIも含めたTodoリストのCRUDの実装です。

## ログインしてみる
起動してログインしてみます。

```
npm start
```

<img width="1552" alt="スクリーンショット 2019-05-04 22.03.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258226/8b1e1317-5d60-001b-5e07-e33b0bc0cffb.png">
このような画面が表示されたらサインアップしてサインインします。

![画面収録 2019-05-04 22.08.21.mov.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258226/2bf81804-0e61-61c5-b9e4-a6f17fdd0b5f.gif)
Todoアプリを作ることができました。
最後にS3にホストします。

```
amplify hosting add

# 環境：開発環境
? Select the environment setup: DEV (S3 only with HTTP)

# S3バケット名
? hosting bucket name simple-todo-20190504221430-hostingbucket

# indexファイル：index.html
? index doc for the website index.html

# エラー時ファイル：index.html
? error doc for the website index.html

You can now publish your app using the following command:
Command: amplify publish
```

```
amplify publish
```

公開が完了して自動的にページが開かれれば完了です。