AWS Amplify ＋ Nuxt.js でユーザーログイン可能なウェブチャットアプリを作成

AWSのAmplifyは、GraphQLを設計するだけで、バックエンド側コードとかリソースとかその設定を自動生成してくれるので、フロントエンドのコードをjsとかで書くだけでウェブアプリケーションを作れてしまいます。
ドキュメントを一通り読んでみて勉強を兼ねてユーザー管理できるウェブチャットアプリを作ってみたのでメモ

# 前提
- AWSのアカウントは取得済み
- Amplify CLIはインストール＋設定済み
- Nuxt.jsをインストールする準備は完了済み

## 環境
- フロント: NuxtJS + Vuetify
- バックエンド: AWS Amplify
- API設計: GraphQL

環境のための参考資料：
[Amplify CLI インストール](https://docs.amplify.aws/start/getting-started/installation/q/integration/vue)
[Nuxt.js インストール](https://ja.nuxtjs.org/guide/installation)

# 作るもの

かんたんなウェブチャットツールです。
ユーザー登録＋ログインして、チャットにコメントを投稿できます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73833/66f4e812-68b5-f0c7-90f1-8e0d930bf03c.png" width="70%">
練習なのでこんな感じのシンプルなものです。

## ポイント
- ユーザー登録、ログインができる。
- ユーザー毎にコメントできる
- コメントはリアルタイムで画面に反映
- ログインしないとコメント投稿・閲覧ができない
- 投稿したコメントはDBに保存されて再度ログインしたときには続きが書き込める

というあたりを目指します。

# Nuxt.jsインストール

とりあえずフロント側のNuxtをインストールして基本的なページを作ります。プロジェクト名は`nuxt-amplify`としました。
オプションは下のような感じで選択しました。

```zsh
% npx create-nuxt-app nuxt-amplify

create-nuxt-app v3.0.0
✨  Generating Nuxt.js project in nuxt-amplify
? Project name nuxt-amplify
? Choose programming language JavaScript
? Choose the package manager Npm
? Choose UI framework Vuetify.js
? Choose Nuxt.js modules Axios, Progressive Web App (PWA) Support, Content
? Choose linting tools ESLint, Prettier, Lint staged files, StyleLint
? Choose test framework Jest
? Choose rendering mode Single Page App
? Choose development tools jsconfig.json (Recommended for VS Code)
```

## 基本的なページの作成

フロント側のページで見た目を適当に整えます。
必要なのは、コメントを入力するテキストボックスと一覧表示する部分だけです。

```vue:pages/chat.vue
<template>
  <div style="max-width: 800px;">
    <v-text-field
      label="コメント"
      placeholder="ここにコメントを書きましょう"
      outlined
      class="mx-auto"
      append-icon="mdi-check-bold"
      style="max-width: 100%; box-sizing: border-box;"
    ></v-text-field>

    <v-card v-for="(item, index) in items" :key="index" tile>
      <v-list-item two-line>
        <v-list-item-content>
          <v-list-item-title>{{ item.comment }}</v-list-item-title>
          <v-list-item-subtitle>by: {{ item.owner }}</v-list-item-subtitle>
        </v-list-item-content>
      </v-list-item>
    </v-card>
  </div>
</template>
<script>
export default {
  data() {
    return {
      form: {
        comment: '',
      },
      items: [],
    }
  },
  created() {
    this.getChatList()
  },
  methods: {
    getChatList() {
      // コメント取得
      this.items = [
        {
          comment: 'ここにコメントが入ります',
          owner: 'ここに投稿者名が入ります',
        },
        {
          comment: 'ここにコメントが入ります',
          owner: 'ここに投稿者名が入ります',
        },
        {
          comment: 'ここにコメントが入ります',
          owner: 'ここに投稿者名が入ります',
        },
      ]
    },
  },
}
</script>

```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73833/9ebe8f3b-3654-4a0f-7831-0ec9b9671de8.png" width="70%">
こんな感じの見た目に。

# Amplifyのインストール

フロント側さえつくれば、あとはAmplifyをインストールして必要な設定をしていくだけで、アプリケーションとして動くようになります。
まずは、現行のプロジェクトにAmplifyをインストールしていきます。
先程作ったNuxt.jsプロジェクトディレクトリのルートで `amplify init` コマンドを実行します。
選択オプション等は以下のようにしました。

```zsh
% amplify init

? Enter a name for the project nuxtamplify #任意のプロジェクト名
? Enter a name for the environment dev
? Choose your default editor: Visual Studio Code
? Choose the type of app that you re building javascript

? What javascript framework are you using vue
? Source Directory Path:  . # ルートを指定
? Distribution Directory Path: dist # ビルド済ファイルの保存ディレクトリを指定
? Build Command:  npm run build # ビルドコマンドを指定（※）
? Start Command: npm run start # 起動コマンドを指定（※）

? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use default
```

最後方のコマンド関連の質問は環境などにより変わると思います。
package.jsonなどを参考に。


# vue.js用のライブラリを読み込み

Vue.jsでamplifyと通信したり色々できるライブラリが準備されているので追加します。

```zsh
npm install aws-amplify @aws-amplify/ui-vue
```

## Nuxt.jsのプラグインとして設定

Nuxt.jsのプラグインとして使えるように設定します。
まずはプラグインファイルを作成します。

```js:plugins/amplify.js
import Vue from 'vue'
import Amplify from 'aws-amplify'
import '@aws-amplify/ui-vue'
import awsExports from '../aws-exports'

Amplify.configure(awsExports)
Vue.use(Amplify)
```

`nuxt.config.js`のプラグイン設定の配列に`{ src: '~/plugins/amplify.js', ssr: false }`を追加します。

```js:nuxt.config.js
//...

// pluginsの配列に追加します。
plugins: [{ src: '~/plugins/amplify.js', ssr: false }],

//...
```

# バックエンドAPIを作成

いよいよAWS側にバックエンドエンド側を作成してエンドポイントを生成します。
ここからはコマンドと少しのコードであっという間に本格的なウェブアプリケーションが作成されていきます。
まずは、AmplifyにAPI機能を追加
`amplify add api` コマンドを実行するだけです。
オプション設定は以下のように答えました。

```zsh
% amplify add api

? Please select from one of the below mentioned services: GraphQL
? Provide API name: chat # 任意のAPI名
? Choose the default authorization type for the API API key
? Enter a description for the API key: sample
? After how many days from now the API key should expire (1-365): 7
? 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? Yes
? What best describes your project: Single object with fields (e.g., “Todo” with ID, name, description) 
? Do you want to edit the schema now? Yes
```

これで必要なコードがプロジェクトに追加されます。

## GraphQLでバックエンドの設計

バックエンドのAPIをGraphQLで設計します。
基本ファイルが出来上がっているので
`amplify/backend/api/`下にあるファイルを開いて以下のように書き換えます。

```graphql:amplify/backend/api/chat/schema.graphql
type Chat @model {
  id: ID!
  comment: String!,
  owner: String
}
```
`Chat` というモデル（テーブル）に、`id`, `comment`, `owner`というフィールドを作成して保存したり取得できるようにします。
バックエンド設計で必要なファイルはこれだけ。
GraphQLというAPI設計用のクエリ言語なんですが、これを元にバックエンド側のDB保存やAPIへのリクエスト処理などを全部自動生成してくれます。
詳しくは
- [GraphQL](https://graphql.org/) （基本を学ぶ）
- [API(GraphQL) Directives](https://docs.amplify.aws/cli/graphql-transformer/directives) （Amplifyで使うディレクティブを学ぶ）
あたりを参考にどうぞ。
（自分もまだ学びはじめたばかりですが、いままでウェブのバックエンドのコード書いていた者にとってすごくワクワクできる内容です）

## AWS側に送信して必要なリソースを生成

上記のを書いたらあとはAWS側で必要なリソースが自動的に生成されて連携して動くようにしてくれます。
`amplify push` コマンドするだけ。

```zsh
% amplify push

? Are you sure you want to continue? Yes

? 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
```
ちょっと時間かかりますが、待っていると（たまに質問してくるけど基本デフォルトで）、自動的にAWS側の設定をしてくれます。
以上で、APIエンドポイントの生成が完了です。
ほとんどコード書かないまま実装されていきます。

（※）
`Enter the file name pattern of graphql queries...`
のところ、GraphQL関連のファイルの生成場所なんですが、Nuxt.jsの場合どこに置くのがよいのかな。もっとスマートな指定がありそう。。

# フロント側をAPIエンドポイントと通信できるように修正

エンドポイントが動くようになったので、フロントから通信するようにコードを修正します。

```html:pages/chat.vue>template
<template>
・・・
// v-modelとイベントハンドラを追加
<v-text-field
        ...
        v-model="form.comment"
        @keydown="onEnter"
        @click:append="createChat"
      ></v-text-field>

...
<template>
```

```javascript:pages/chat.vue>script
<script>
import { API } from 'aws-amplify' // Amplifyライブラリを読み込み
import { createChat } from '~/src/graphql/mutations' // GraphQL Mutation（データをエンドポイントに送信する構文?）
import { listChats } from '~/src/graphql/queries' // GraphQL Query（データを読み込む構文？）

export default {
  // ...
  methods: {
    async createChat() {
      // コメントを送信する
      const comment = this.form.comment // コメント入力値を取得
      if (!comment) return // 空のときは処理しない
      const chat = { comment } // 送信用のJSONを作成
      // 送信処理
      await API.graphql({
        query: createChat, // GraphQL Mutation
        variables: { input: chat }, // 送信データ
      })
      this.form.comment = '' // 送信後にテキストフィールドを空に。
    },
    onEnter(event) {
      // ここはおまけ。（Enterを押したときもコメントを送信したかったので記述）
      if (event.keyCode !== 13) return
      this.createChat()
    },
    async getChatList() {
      // コメント一覧を取得
      const chatList = await API.graphql({
        query: listChats, // GraphQL Query
      })
      this.items = chatList.data.listChats.items // 読み込みしたデータを一覧に表示
    },
  },
}
</script>

```
`mutations`と`queries`は、`amplify push`した時に設定したディレクトリに生成されています。
それぞれの内容を見るとより理解が深まりますし、応用でいろいろできると思います。


## リアルタイムに反映されるようにコードを追記

また、送信と読み込みだけでなく、送信して保存された内容がリアルタイムに画面に反映されるようにコードを追記します。
GraphQLのSubscriptionという機能(仕様?)を使います。サーバからのpush通信などを受け取ったりできます。

```javascript:pages/chat.vue>script
<script>
// ...
import { onCreateChat } from '~/src/graphql/subscriptions' // GraphQL Subscription

export default {
  // ...
  created() {
    this.getChatList() 
    this.subscribe() // 追加
  },
  methods: {
    //...
    
    // メソッド追加
    subscribe() {
      API.graphql({ query: onCreateChat }).subscribe({ 
        next: (eventData) => {
          // コメントが送信されて追加されたとき、送信内容を一覧に追加
          const chat = eventData.value.data.onCreateChat // データを読み込み
          if (this.items.some((item) => item.comment === chat.comment)) return // すでに表示されているデータは無視
          this.items = [...this.items, chat] // 新しいデータを追加
        },
      })
    },
  },
}
</script>
```

以上で、APIへの対応を完了。
チャットアプリケーションとして、動くようになりました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73833/3e1e6356-02ad-5d27-9a8d-97aa91abd34e.gif" width="50%">


コメント欄にテキストを入力して、Enterかチェックマークをクリックすると一覧にデータが反映されるようになっていると思います。
（投稿者名は空になっていますが、これから実装します）
また、AWSのコンソールにログインしてみると、DynamoDBにテーブルが作られて、そこにデータが保存されているのがわかると思います。
ほとんど、フロント側のコードだけで、ここまでのウェブアプリを作ることができます。

# ユーザー管理機能を追加

続いてこのアプリケーションにユーザー管理機能を追加してログインした人だけが投稿できるようにしたり、投稿者の名前を表示したりできるようにしていきます。
まずは、Amplifyにユーザー認証機能を追加します。
`amplify add auth` コマンドを実行するだけです。

```zsh
% amplify add auth

Do you want to use the default authentication and security configuration? Default configuration
How do you want users to be able to sign in? Username
Do you want to configure advanced settings? No, I am done.
```

機能が追加されたら、そのままAWSにpushします。
少し質問されるかもですが、とりあえずデフォルトのままで。

```zsh
% amplify push
```
以上でバックエンド側にユーザー認証機能が実装されました。

## フロント側をユーザーログイン機能に合わせて修正

```html:pages/chat.vue
//...

<amplify-authenticator>
      <v-text-field
        v-model="form.comment"
        label="コメント"
        placeholder="ここにコメントを書きましょう"
        outlined
        class="mx-auto"
        append-icon="mdi-check-bold"
        style="max-width: 100%; box-sizing: border-box;"
        @keydown="onEnter"
        @click:append="createChat"
      ></v-text-field>
</amplify-authenticator>

//...
```

これだけです。入力テキストフィールドを `amplify-authenticator` で囲むだけ。
これで、ログインしているときは、テキストフィールドが表示され、ログインしていないときは、ログイン用のフィールドが表示されます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73833/e43e061f-2e31-cabe-f0d2-f79a68a9c395.png" width="50%">
こんな感じのログイン画面を自動的に生成してくれます。
ユーザー登録や、パスワードリセットも実装されているので、非常に嬉しい。
また、いろいろカスタマイズもできます。
https://docs.amplify.aws/ui/auth/authenticator/q/framework/vue

## 確認
では、実際にアカウントを作成してログインしてみてください。
ログインができてアプリを使えるようになっていると思います。

## APIへの認証を追加
見た目的には、ログイン機能が実装されましたが、API自体に認証が追加されたわけではないので、直接エンドポイントにアクセスするなどすればデータの読み書きができてしまいます。
そこで、APIを今回追加した認証機能と連携させてユーザーログインしている人だけがエンドポイントにアクセスできるようにAPI設計を修正します。
とはいえ、`schema.graphql`に少し追加するだけです。

```graphql:amplify/backend/api/chat/schema.graphql
type Chat @model @auth(rules: [{ allow: owner, operations: [create, delete, update] }]) {
  id: ID!
  comment: String!,
  owner: String
}
```
`@auth(rules: [{ allow: owner, operations: [create, delete, update] }])`
を追加しました。
ownerフィールドに保存されたユーザー名で認証（ユーザー名は自動的に保存されます）して、create,delete,updateに対して操作を許可します。
これで、Chatモデルは、データの読み込みはログインしていれば誰でも可能、書き込みは作成者でなければできない。という状態になります。
参考：
https://docs.amplify.aws/cli/graphql-transformer/directives#auth

これをAWS側にpushします。

```zsh
% amplify push
```

## エラーが出た場合
ここでエラーが出た場合（上記の通りやっているとエラーになります）は対応が必要です。
apiをauthに合わせて更新しないといけません。
API認証を、AWSのCognitoで行うように更新します。
`amplify update api` コマンドでAPI設定の更新を行います。

```zsh
% amplify update api

? Please select from one of the below mentioned services: GraphQL
? Select from the options below Walkthrough all configurations
? Choose the default authorization type for the API Amazon Cognito User Pool #Cognito User Pool を選択してください。
Use a Cognito user pool configured as a part of this project.
? Do you want to configure advanced settings for the GraphQL API No, I am done.
```
これで暫く待つとAWS側の設定が更新されます。
あとは、もう一度

```zsh
% amplify push
```

で、GraphQLの設定がpushされます。
エンドポイントへの認証設定も完了です。

また、この設定により、DBにowner名が保存されるようになりました。
コメントを投稿したときにチャットツールの投稿者名も表示されるようになっていると思います。

## ログインしたときに、一覧表示される（以降リアルタイムで更新される）ように修正

エンドポイントへの認証設定をしたので、すこし不都合がでてきます。

1. ログアウト状態で画面を開く（この状態では一覧読み出しができない）
2. ログインする。（一覧の読み出しはできるようになったが、すでにページは生成されているのでページを更新しないと一覧が表示されない）

という状態になってしまっています。
ログインしたときに、APIから一覧を読み出す処理と、それ以降はリアルタイムで更新されていくように修正します。

```javascript:pages/chat.vue
<script>
import { API, graphqlOperation } from 'aws-amplify'
//...
import { onAuthUIStateChange } from '@aws-amplify/ui-components'

//...
  data() {
    return {
//...
      username: '',
//...
    }
  },

  created() {
    this.getChatList()
    // this.subscribe()

    // 追加
    onAuthUIStateChange((authState, authData) => { // ログインステータスが変化したとき
      if (authState === 'signedin') { // ログインした場合
        this.username = authData.username
        this.getChatList() // 一覧呼び出し
        this.subscribe() // GraphQL Subscription
      } else {
        this.items = [] // ログアウトしたときなどは一覧を削除
      }
    })
  },

  methods: {
    //...
    
    // ユーザー名を渡すように修正
    subscribe() {
        API.graphql(
          graphqlOperation(onCreateChat, { owner: this.username })
        ).subscribe({ 
        next: (eventData) => {
          // コメントが送信されて追加されたとき、送信内容を一覧に追加
          const chat = eventData.value.data.onCreateChat // データを読み込み
          if (this.items.some((item) => item.comment === chat.comment)) return // すでに表示されているデータは無視
          this.items = [...this.items, chat] // 新しいデータを追加
        },
      })
    },
  },

// ...
</script>

```
ログイン状態を監視するようにイベントを設定しました。

これで一通り作成が完了しました。
`chat.vue` はこのようになりました。
また、ついでにログアウトボタンも実装しています。（`amplify-sign-out`）

```vue:pages/chat.vue
<template>
  <div style="max-width: 800px;">
    <amplify-authenticator>
      <v-text-field
        v-model="form.comment"
        label="コメント"
        placeholder="ここにコメントを書きましょう"
        outlined
        class="mx-auto"
        append-icon="mdi-check-bold"
        style="max-width: 100%; box-sizing: border-box;"
        @keydown="onEnter"
        @click:append="createChat"
      ></v-text-field>
    </amplify-authenticator>

    <v-card v-for="(item, index) in items" :key="index" tile>
      <v-list-item two-line>
        <v-list-item-content>
          <v-list-item-title>{{ item.comment }}</v-list-item-title>
          <v-list-item-subtitle>by: {{ item.owner }}</v-list-item-subtitle>
        </v-list-item-content>
      </v-list-item>
    </v-card>

    <v-card>
      <amplify-sign-out v-if="logoutBtn"></amplify-sign-out>
    </v-card>
  </div>
</template>
<script>
import { API, graphqlOperation } from 'aws-amplify'
import { onAuthUIStateChange } from '@aws-amplify/ui-components'
import { createChat } from '~/src/graphql/mutations'
import { listChats } from '~/src/graphql/queries'
import { onCreateChat } from '~/src/graphql/subscriptions'

export default {
  data() {
    return {
      form: {
        comment: '',
      },
      items: [],
      logoutBtn: false,
      username: '',
    }
  },
  created() {
    this.getChatList()

    onAuthUIStateChange((authState, authData) => {
      if (authState === 'signedin') {
        this.username = authData.username
        this.getChatList()
        this.subscribe()
        this.logoutBtn = true
      } else {
        this.items = []
        this.logoutBtn = false
      }
    })
  },
  methods: {
    async createChat() {
      const comment = this.form.comment
      if (!comment) return
      const chat = { comment }
      await API.graphql({
        query: createChat,
        variables: { input: chat },
      })
      this.form.comment = ''
    },
    onEnter(event) {
      // エンターキーで送信
      if (event.keyCode !== 13) return
      this.createChat()
    },
    async getChatList() {
      // コメント取得メソッド
      const chatList = await API.graphql({
        query: listChats,
      })
      this.items = chatList.data.listChats.items
    },
    subscribe() {
      API.graphql(
        graphqlOperation(onCreateChat, { owner: this.username })
      ).subscribe({
        next: (eventData) => {
          const chat = eventData.value.data.onCreateChat
          if (this.items.some((item) => item.comment === chat.comment)) return // 重複しないように
          this.items = [...this.items, chat]
        },
      })
    },
  },
}
</script>

```

ログインしてコメントを書き込めるようになりました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73833/11e792c0-4a6a-69a6-52cf-43a743d275e9.gif" width="50%">


# デプロイ

最後は実際にAmplifyでホスティングします。

```zsh
% amplify add hosting

? Select the plugin module to execute Hosting with Amplify Console 
? Choose a type Manual deployment
```

```zsh
% amplify publish
```

しばらくまつとpublish完了。
URLが表示されるので、アクセスしてみましょう。
あっというまに、ウェブアプリケーションが作成されました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73833/12f66b21-73ad-c949-76ab-d521c2bad02b.gif" width="50%">

あとは、せっかくチャットツールをつくったので、シークレットウィンドウや別のブラウザを並べて一人二役などでチャットをしてみましょう。
わずかな手間でここまで実装できることへの驚きと満足感と、一人でチャットをする若干の寂しさを感じることができます。

# 削除
せっかく作りましたがAWSにアップしたままにするのもアレなんで削除方法も。

```zsh
% amplify delete
```
で削除できます。かんたんですね。
また、下記のアクセス制限などをしてから、しばらく色々試してみるのも楽しいです。


# 補足

一応アクセス制限をかけられます。
AWSのコンソールから、AWS Amplifyにアクセスして、[アクセスコントロール]の項目からベーシック認証的なものをかけることができます。

## ユーザー認証つけたときのsubscribeのエラー

久しぶりにいじったらユーザー認証下で動かすときにsubscribeが動作しなくなっていたのでコードを修正しました。（上記は修正済みコードです）

```:こんな感じのエラー
Uncaught {provider: AWSAppSyncRealTimeProvider...
"Connection failed: {"errors":[{"message":"Variable 'owner' has coerced Null value for NonNull type 'String!'"}]}"
```

## Nuxtのエラー？
今回のNuxtでコンソールにエラーがでます。（たぶんVuetify関連）
`'v-content' is deprecated, use 'v-main' instead`
みたいなやつです。
本筋と関係ないですし、練習なので無視してもよいのですが、気になる場合は
`layouts/default.vue` の、`v-content`を`v-main`に修正すると直ります。
https://github.com/vuetifyjs/vuetify/issues/11634

# 参考
今回参考にさせていただいたサイト
https://docs.amplify.aws/start/q/integration/vue
https://docs.amplify.aws/cli/graphql-transformer/directives#using-modular-imports
https://ja.nuxtjs.org/
https://vuetifyjs.com/ja/
https://qiita.com/fkymnbkz/items/fa7cd15de1039e62074e
https://github.com/aws-amplify/amplify-cli/issues/3480
https://github.com/vuetifyjs/vuetify/issues/11634
https://remoter.hatenablog.com/entry/2020/02/28/Nuxt%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%81%A7Amplify%E3%82%92%E8%A7%A6%E3%81%A3%E3%81%A6%E3%81%BF%E3%82%8B
https://qiita.com/respectakagikun/items/0b976b12ddb34f027190