Vue.js×GraphQL(AppSync)×AmplifyでTODOリストを作る

この記事は [Vue.js #3 Advent Calendar 2018](https://qiita.com/advent-calendar/2018/vuejs-3) の14日目の記事です。

# はじめに

ある日、「Vue.jsとGraphQLでなんか簡単な画面を作るハンズオン的なのできないか？」という要請が来たので、この記事を書きました。
ついでにAmplifyCLIを使えば、より簡単にAWSのサービスを構築することができてアプリ側からの連携も楽になるので、Amplifyも採用しました。

この記事では、Vue.jsでタイトルと内容TODOリスト(みたいなもの)を作成/削除するアプリケーションを、AWSにバックエンドのサービスをデプロイしながら開発していくことを体験することを目的に書いています。お時間のある方はぜひ試してください。

## 前提条件

下記がインストール済みであること。

* Node.js / npm
* AWSCLI

## アーキテクチャ

フロントはVue.js(以下、Vue)で書きます。ログイン機能をつけるのでAmazonCognito(以下、Cognito)を採用。
GraphQLはAWSAppSync(以下、AppSync)を使います。TODOリストのデータはDynamoDBに格納します。

<img width="843" alt="todo_list.png" src="https://qiita-image-store.s3.amazonaws.com/0/95875/96cf87ee-1b2a-b82f-685a-0670774ff525.png">

## 完成品

![todo_list.gif](https://qiita-image-store.s3.amazonaws.com/0/95875/7fc6fa07-0840-1512-cdee-e55b1e97fab7.gif)

## 手順1. Vue-CLIのインストール

Vueプロジェクトをビルドするためのツールをインストールします。ここではVue-CLI3を使っていきます。

```
$ npm install -g @vue/cli
```

インストールが完了したら、バージョンを確認します。

```
$ vue -V
```

## 手順2. Vueプロジェクト作成

プロジェクトを作成します。

```
$ vue create vue-todo-list
```

詳細設定をしていきます。

![スクリーンショット 2018-12-12 12.38.50.png](https://qiita-image-store.s3.amazonaws.com/0/95875/8cbb42a0-bdc0-5f33-eba2-698e0efbbc49.png)

最後まで設定すると自動的にビルド環境がローカルに用意されます。成功すると下のようなメッセージが表示されます。

![スクリーンショット 2018-12-11 17.03.26.png](https://qiita-image-store.s3.amazonaws.com/0/95875/210b3110-5d2d-1b64-c752-2d169f5a89e5.png)

`cd vue-todo-list` して `yarn serve` とすると、このようなデフォルト画面が表示されます。

![スクリーンショット 2018-12-11 17.04.49.png](https://qiita-image-store.s3.amazonaws.com/0/95875/639770e6-19dd-e530-9338-d96a1e865ec9.png)

今回TemplateにPugを利用するので、Pugをビルドするためのパッケージをインストールします。

```
$ vue add pug
```

これでVueプロジェクトの作成は完了です。

## 手順3. AmplifyCLIでAWSサービスを準備する

### そもそもAmplifyとは？

AmplifyはAWSリソースとの接続を支援するオープンソースのライブラリです。
強力なCLIツールが提供されていて、AWSリソースの作成をコマンド上で実行し、ライブラリに反映することができます。 現在はCognitoによる認証や、AppSyncを用いたGraphQLの利用などがサポートされています。
参考)[**AWSAmplifyの公式ドキュメント**](https://aws-amplify.github.io/)

### AmplifyCLIのインストール

Vue-CLIと同様にnpmでインストールします。

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

インストールできているか確認します。

```
$ amplify
```

![スクリーンショット 2018-12-11 18.01.40.png](https://qiita-image-store.s3.amazonaws.com/0/95875/a29342f9-59f7-7b34-0563-d147590f6a4a.png)


### Amplify用のユーザー作成

AmplifyCLIで利用するユーザーを作成します。
ここではAdminユーザーを作成して、 `amplify` というProfile名でAWSCLIに保存します。
リージョンは `ap-northeast-1` (東京リージョン)を、ユーザー名はデフォルトのまま作成してください。
作成したIAMユーザのアクセスキーとシークレットアクセスキーを入力する必要があるので、CSVをダウンロードするなりして保管しておいてください。
最後にAWSCLIのプロファイルとしてユーザーの情報を保存させるので、好きな名前を付けて保存してください。下の画像は `amplify` というプロファイル名で保存しています。

```
$ amplify configure
```

![スクリーンショット 2018-12-11 18.03.30.png](https://qiita-image-store.s3.amazonaws.com/0/95875/83a9bb3f-39f3-33ee-4e28-8a8aeed6e7d2.png)

### Amplifyの初期化

IDEや言語、どういったFrameworkを使っているのかを設定していきます。
途中でどのIAMユーザーを利用するか？と聞かれるのでAdmin権限を持っているユーザーを選択します。
※青くなっている文字が選択した内容です。

```
$ amplify init
```

![スクリーンショット 2018-12-11 18.04.08.png](https://qiita-image-store.s3.amazonaws.com/0/95875/963bd6d9-58f7-8116-365f-9bff6d3096b8.png)

`Successfully created ...` というメッセージが表示されたので、AmplifyCLIを利用する準備ができました。

## 手順4. Cognitoをデプロイする

ログインのバックグラウンドとしてCognitoを利用するので、AmplifyCLIでデプロイしていきましょう。

### そもそもCognitoって？

ログインなどの認証機能を実装する際に便利なマネージドサービスです。Serverlessにログイン認証を提供してくれます。
ユーザー情報などをCognitoで持ってくれるので、別にDBなどを用意する必要がありません。
参考)[**Cognitoの公式サイト**](https://aws.amazon.com/jp/cognito/)

### デプロイ準備

まずどのような設定でCognitoをデプロイするかを決めます。
今回はデフォルトのままで問題ないです。

```
$ amplify auth add
```

### デプロイ実行

実際にデプロイしましょう

```
$ amplify auth push
```

`✔ All resources are updated in the cloud` というメッセージが出れば成功です。
コンソール画面でCognitoが作成されているの確認することができます。

## 手順5. AppSyncをデプロイする

GraphQLを利用するためにAppSyncを用意します。

### そもそもAppSyncって？

AWSのサービスに対して、GraphQLで操作するためのサービスです。
GraphQLを利用して、DynamoDBのデータを操作したり、Lambdaをinvokeしたりすることができます。
参考)[**AppSyncの公式サイト**](https://aws.amazon.com/jp/appsync/)

### デプロイ準備

下の画像の青くなっている文字が選択した内容です。

* `Provide API name` には任意のAPI名をつけてください。
* APIをCognito認証で利用するので、 `Amazon Cognito User Pool` を選択。
* TODOリストを作成するためのSchemaが用意されているので、今回はすでに用意されているSchemaを利用します。

```
$ amplify api add
```

![スクリーンショット 2018-12-12 9.57.08.png](https://qiita-image-store.s3.amazonaws.com/0/95875/0a0729fd-4ab1-ce6f-38d2-5f9f7c157d33.png)

### デプロイ

準備ができたのでデプロイしていきましょう。
途中で「GraphQLAPIのコードを作るか？」と聞かれますが、一旦ここでは作らずに進めます。

```
$ amplify api push
```

`✔ All resources are updated in the cloud` というメッセージが出れば成功です。
このタイミングでAppSyncのAPIとDynamoDBのテーブルが作成されています。

これでAWSの構築が完了しました。
この状態で `./src` 以下に `aws-exports.js` が生成されているので、このファイルをコピーして `aws-exports.ts` を作成しておいてください。

## 手順6. 画面の作成

AWS側の準備が整ったので、Vueのアプリを作成していきましょう。

### プラグインのインストール

VueのUIコンポーネントである `Element UI` と　`AWSAmplify` 、 `@types/node` インストールします。

```
$ npm i --save-dev element-ui aws-amplify @types/node
```

インストールできたらVueに読み込ませます。

```ts:main.ts
import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";

// モジュールの読み込み
import ElementUI from "element-ui";  
import "../node_modules/element-ui/lib/theme-chalk/index.css";
import Amplify from "aws-amplify";
import appSyncConfig from "./aws-exports";

Amplify.configure(appSyncConfig); // 追加

Vue.config.productionTip = false;
Vue.use(ElementUI); //追加

new Vue({
  router,
  store,
  render: (h) => h(App),
}).$mount("#app");

```

また `@types/node` を有効にするために、インストールしたあと `tsconfig` に `node` を追加します。

```json:tsconfig.json
...
    "types": [
      "webpack-env",
      "node" // 追加
    ],
...
```

#### エラーが発生する場合は…

##### `Could not find a declaration file for module 'graphql/language/ast'...` や `Could not find a declaration file for module '@aws-amplify/ui'...` というエラーメッセージが出る。

`noImplicitAny` オプションを無効にします。 

```json:tsconfig.json
{
  "compilerOptions": {
    "noImplicitAny": false,
...
  }
...
}
```

### 親Component(App.vue)の修正

上部にあるメニューが必要ないので、削除しましょう。

```vue:App.vue
<template lang="pug">
  #app
    router-view
</template>

<style lang="scss">
body {
  background-color: #2c3e50;
  margin: 0;
}

#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #ffffff;
}

.title {
  font-size: 4rem;
  margin-top: 10vh
}
</style>

```

### SignInページのVueファイル

`src/views/` 以下に `SignIn.vue` を作成し、内容は下記です。

```vue:src/views/SignIn.vue
<template lang="pug">
.signin-page
  .title SignIn
  form.signin-form(@submit.prevent="signIn")
    el-row.user-info.input-area
      el-col(:span="6")
        .text ID
      el-col(:span="12")
        el-input(v-model="userID")
    el-row.password-info.input-area
      el-col(:span="6")
        .text Password
      el-col(:span="12")
        el-input(type="password" v-model="password")
    el-button.auth-button(type="primary" native-type="submit") サインイン
    el-button.auth-button(type="info" @click="linkSignUp") サインアップする
</template>

<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { Auth } from "aws-amplify";
import router from "@/router";

@Component({})
export default class SignIn extends Vue {
  password: string = "";
  userID: string = "";

  public signIn() {
    const self = this;
    Auth.signIn(self.userID, self.password)
      .then(user => {
        return router.push("/");
      }).catch(err => {
        console.error(err);
      });
  }

  public linkSignUp() {
    return router.push("/signUp");
  }
}
</script>

<style scoped lang="scss">
.signin-form {
  margin: 40px auto 0;
  width: 40vw;

  .input-area {
    line-height: 60px;
    margin: 10px 0;
  }

  .auth-button {
    font-weight: bold;
    margin-top: 20px;
    width: 40%;
  }
}
</style>
```

### SignUpページのVueファイル

`src/views/` 以下に `SignUp.vue` を作成し、内容は下記です。

```vue:src/views/SignUp.vue
<template lang="pug">
.signup-page
  .title SignUp
  form.signup-form(@submit.prevent="signUp" v-show="signupForm")
    el-row.email-info.input-area
      el-col(:span="6")
        .text EmailAddress
      el-col(:span="12")
        el-input(v-model="email")
    el-row.user-info.input-area
      el-col(:span="6")
        .text ID
      el-col(:span="12")
        el-input(v-model="userID")
    el-row.password-info.input-area
      el-col(:span="6")
        .text Password
      el-col(:span="12")
        el-input(type="password" v-model="password")
    el-button.auth-button(type="primary" native-type="submit") サインアップ
  form.signup-form(@submit.prevent="userVerify" v-show="!signupForm")
    el-row.email-info.input-area
      el-col(:span="6")
        .text VerifyCode
      el-col(:span="12")
        el-input(v-model="verifyCode")
    el-button.auth-button(type="primary" native-type="submit") ユーザーの有効化
</template>

<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { Auth } from "aws-amplify";
import router from "@/router";

@Component({})
export default class SignUp extends Vue {
  email: string = "";
  password: string = "";
  signupForm: boolean = true;
  userID: string = "";
  verifyCode: string = "";

  public signUp() {
    const self = this;
    Auth.signUp(self.userID, self.password, self.email)
      .then(user => {
        self.signupForm = false;
      }).catch(err => {
        console.error(err);
      });
  }

  public userVerify() {
    console.log("verify!");
    const self = this;
    Auth.confirmSignUp(self.userID, self.verifyCode)
      .then(data => {
        alert("登録完了しました");
        return router.push("/signIn");
      }).catch(err => {
        console.error(err);
      });
  }
}
</script>

<style scoped lang="scss">
.signup-form {
  margin: 40px auto 0;
  width: 40vw;

  .input-area {
    line-height: 60px;
    margin: 10px 0;
  }

  .auth-button {
    font-weight: bold;
    margin-top: 20px;
    width: 40%;
  }
}
</style>

```

### TODOリストページのVueファイル

`src/views/` 以下に `TODO.vue` を作成し、内容は下記です。

```vue:src/views/TODO.vue
<template lang="pug">
.todo-list-page
  .title TODO List
  .list-area
    el-button.signout(@click="signOut") SignOut
    el-col.todo-card(:span="8")
      el-card.box-card
        .card-header(slot="header")
          el-input(v-model="cardTitle")
        .card-body
          el-input(type="textarea" :rows="10" v-model="cardBody")
        el-button.card-button.card-create-button(type="primary" @click="create") 作成
    el-col.todo-card(:span="8" v-for="item in listItems" :key="item.name")
      el-card.box-card
        .card-header(slot="header")
          .text {{ item.name }}
        .card-body
          .text {{ item.description }}
        el-button.card-button.card-delete-button(type="danger" @click="remove(item.id)") 削除
</template>

<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { Auth, API, graphqlOperation } from "aws-amplify";
import router from "@/router";

type listItemType = {
  id: string,
  name: string,
  description: string
};

@Component({})
export default class TODO extends Vue {
  cardBody: string = "";
  cardTitle: string = "";
  listItems: listItemType[] = [];

  async created() {
    await this.getListItems();
  }

  // サインアウト処理
  public signOut() {
    Auth.signOut().then(data => {
      return router.push("/signIn");
    }).catch(err => {
      console.error(err);
    });
  }

  // TODOリストの作成
  public async create() {
    console.log(this);
    const gqlBody = `
      mutation create {
        createTodo(input: {
          name: "${this.cardTitle}"
          description: "${this.cardBody}"
        }) {
          id
          name
          description
        }
      }
    `;
    const result: any = await API.graphql(graphqlOperation(gqlBody));
    this.listItems.unshift(result.data.createTodo);
  }

  // TODOリストの削除
  public async remove(id: string) {
    console.log(id);
    const gqlBody = `
      mutation delete {
        deleteTodo(input: {
          id: "${id}"
        }) {
          id
        }
      }
    `;
    const result: any = await API.graphql(graphqlOperation(gqlBody));
    const newListItems: listItemType[] = [];
    this.listItems.filter(item => {
      console.log(item);
      if (result.data.deleteTodo.id !== item.id) {
        newListItems.push(item);
      }
    });
    this.listItems = newListItems;
  }

  // TODOリスト取得
  public async getListItems() {
    const gqlBody = `
      query list {
        listTodos(limit: 10) {
          items {
            id
            name
            description
          }
        }
      }
    `;
    const result: any = await API.graphql(graphqlOperation(gqlBody));
    this.listItems = result.data.listTodos.items;

    console.log(this.listItems);
  }
}
</script>

<style scoped lang="scss">
.list-area {
  margin: 28px auto 0;
  width: 90vw;

  .signout {
    font-weight: bold;
    position: absolute;
    right: 40px;
    top: 40px;
  }

  .todo-card {
    padding: 15px;

    .box-card {
      height: 380px;
      position: relative;

      .card-body {
        text-align: left;
      }

      .el-button {
        font-weight: bold;

        &.card-button {
          bottom: 10px;
          left: 0;
          margin: auto;
          position: absolute;
          right: 0;
          width: 80px;
        }
      }
    }
  }
}
</style>

```

### Routeの設定

`router.ts` の内容を下記のように書き換えます。

```ts:router.ts
import Vue from "vue";
import Router from "vue-router";
import SignIn from "./views/SignIn.vue";
import SignUp from "./views/SignUp.vue";
import TODO from "./views/TODO.vue";

Vue.use(Router);

export default new Router({
  mode: "history",
  base: process.env.BASE_URL,
  routes: [
    {
      path: "/",
      name: "TODO",
      component: TODO,
    },
    {
      path: "/signIn",
      name: "signIn",
      component: SignIn,
    },
    {
      path: "/signUp",
      name: "signUp",
      component: SignUp,
    },
  ],
});

```

これでアプリ側の準備が整いました。

## 手順7. 動作の確認

### サインアップ

「サインアップする」をクリック

<img width="648" alt="vue-todo-list 2018-12-13 13-06-45.png" src="https://qiita-image-store.s3.amazonaws.com/0/95875/98822a03-b4e4-6549-0e3b-74bdd4381212.png">

メールアドレス(EmailAddress) / ユーザー名(ID) / パスワード(Password) を入力して「サインアップ」をクリック

<img width="706" alt="vue-todo-list 2018-12-13 13-07-52.png" src="https://qiita-image-store.s3.amazonaws.com/0/95875/df80cd83-a4e8-9983-2402-33b9928a1168.png">

検証コードがメールアドレスに送信されるので、そのコードを入力して「ユーザーの有効化」をクリック

<img width="677" alt="vue-todo-list 2018-12-13 13-09-33.png" src="https://qiita-image-store.s3.amazonaws.com/0/95875/8aff2729-4976-7d1e-34b0-4de42fed1a6d.png">

これでログインユーザーが作成されました。

### サインイン

先ほど作成したユーザーの情報を入力して「サインイン」をクリック

<img width="702" alt="vue-todo-list 2018-12-13 13-14-21.png" src="https://qiita-image-store.s3.amazonaws.com/0/95875/116d7662-6591-2722-bd30-4876cbbe681a.png">

ログインできるとTODOリストページに遷移します。

<img width="1601" alt="スクリーンショット 2018-12-14 9.49.18.png" src="https://qiita-image-store.s3.amazonaws.com/0/95875/b0b282ed-5239-2817-f313-3a50d5a8132d.png">

### TODOリストの作成/削除

#### 作成

TODOリストを作成します。
タイトルと内容を入力して「作成」をクリック。

<img width="548" alt="vue-todo-list 2018-12-13 13-15-40.png" src="https://qiita-image-store.s3.amazonaws.com/0/95875/8b45f916-c575-9d67-7012-dd1108737adf.png">

追加されました。実際にDynamoDBに書き込まれています。

<img width="1038" alt="vue-todo-list 2018-12-13 13-17-03.png" src="https://qiita-image-store.s3.amazonaws.com/0/95875/2fbed39b-083d-b50e-9c5f-83b5ada6953e.png">

<img width="1267" alt="スクリーンショット 2018-12-14 9.57.37.png" src="https://qiita-image-store.s3.amazonaws.com/0/95875/73497ed0-0996-a5fb-2d12-3c312e39e8a6.png">

#### 削除

TODOリストを削除します。
作成済みのリストにある「削除」をクリック。

<img width="510" alt="vue-todo-list 2018-12-13 13-17-45.png" src="https://qiita-image-store.s3.amazonaws.com/0/95875/066910e4-db6b-f45e-3b70-2d14a64c9675.png">

削除されました。DynamoDBからも削除されています。

<img width="1045" alt="vue-todo-list 2018-12-13 13-18-58.png" src="https://qiita-image-store.s3.amazonaws.com/0/95875/c798d000-9851-6b8d-75e3-73e4fbe56b6a.png">

<img width="1267" alt="スクリーンショット 2018-12-14 9.59.02.png" src="https://qiita-image-store.s3.amazonaws.com/0/95875/8ea73ce0-b115-9068-b71b-9159077d5370.png">

現状できることはこんな感じです。

## さいごに

ここまでできた内容からさらに発展させていきましょう。
例えば、TODOリストの作成と削除まですることができるようになっていますが、更新ができるようにVueファイルを編集してみたり、TODOリストに残せる内容を増やしてみたりなどなど…

またこのアプリを公開するためにデプロイする必要がありますね。
ビルドしてS3にアップロードしてWebサイトホスティングするもよし、AmplifyConsoleを利用してデプロイしても良しです！

アプリをアップデートして更にVueやGraphQL(AppSync)の知識を深めましょう！
ではまた！！！