Rails APIモード + devise_token_auth + Vue.js 3 で認証機能付きのSPAを作る(Vue.js編 その1)

# はじめに
本記事はAPIをRailsのAPIモードで開発し、フロント側をVue.js 3で開発して、認証基盤にdevise_token_authを用いてトークンベースの認証機能付きのSPAを作るチュートリアルのVue.js編の記事(その1)になります。

Rails側のチュートリアルを終わらせてからこちらのチュートリアルに取り組まれることを推奨します。

前回: [Rails編](https://qiita.com/kk-icare/items/c4cd040824c7d6b434d6
)

次回: [Vue.js編(その2)](https://qiita.com/kk-icare/items/b3aca446c24e89e018c4)

# 環境
Vue.js 3.0.5
Vue CLI 4.5.9
npm 6.14.8
node 14.15.0
TypeScript 3.9.7

# Vue.jsのボイラープレートを作る

## インストール

まずはvue/cliをインストールします。
yarnでもOKですが、今回はnpmでインストールを行います。

既にvue/cliをインストールされている方は、

```terminal
$ vue --version
```

でVue CLIのバージョンを確認し、4.5.0よりバージョンが低い場合は、4.5.0以上にアップグレードを行うようにお願いします。
この時、既に作成されているVueアプリの開発に影響が出ることがあるので、
作業ディレクトリ内にのみ最新版のVue CLI を入れるようにしましょう。

### 初めてVue CLIをインストールする場合

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

### 4.5.0よりバージョンが低いVue CLIをインストールしたことがある方

```
$ mkdir 任意のディレクトリ
$ cd 任意のディレクトリ
任意のディレクトリ $ npm install @vue/cli
```

## vue createの実行

無事Vue CLIのインストールに成功したら、vue createコマンドを実行してアプリを作成します。
プロジェクト名はなんでもいいですが、今回はspa-frontとします。

```terminal
$ vue create spa-front
```

初期設定は以下のようにしました。(LintやCSS、パッケージ管理ツールの設定はお好みで)

```terminal
? Please pick a preset: Manually select features
? Check the features needed for your project: Choose Vue version, Babel, TS, Router, CSS Pre-processors, Linter
? Choose a version of Vue.js that you want to start the project with 3.x (Preview)
? Use class-style component syntax? No
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? Yes
? Use history mode for router? (Requires proper server setup for index fallback in production) Yes
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Sass/SCSS (with dart-sass)
? Pick a linter / formatter config: Standard
? Pick additional lint features: Lint on save
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? No
? Pick the package manager to use when installing dependencies: NPM
```

作成終了したら、以下のコマンドを実行しましょう。

```
$ cd spa-front

$ npm run serve

 DONE  Compiled successfully in 1831ms                                                                                         12:34:08


  App running at:
  - Local:   http://localhost:8080/
  - Network: http://192.168.11.9:8080/

  Note that the development build is not optimized.
  To create a production build, run npm run build.

Issues checking in progress...
No issues found.
```

[http://localhost:8080](http://localhost:8080)にアクセスして、
以下のように表示されればボイラープレートの作成は成功です。

![init.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/509ebbca-1d96-a116-bfa6-45b1513318ae.png)

# axiosを用いてAPIを叩く準備

今回はPromiseベースのHTTPクライアントであるaxiosを用いてRailsで作ったAPIを叩きます。
https://github.com/axios/axios

## インストールおよびコード作成

```terminal
$ npm i axios
```

API関連のコードはsrc/apiにまとめます。

コンポーネント内に直接APIを叩くコードを書くと、コンポーネントの責務が増えてしまうため、
別ディレクトリにまとめるようにします。

```
$ mkdir src/api

$ touch src/api/client.ts
```

client.tsには以下のコードを記述してください。

```typescript
import axios from 'axios'

export default axios.create({
  baseURL: process.env.VUE_APP_API_BASE
})
```
process.env.VUE_APP_API_BASEは、環境変数からデータを取得する記述方法です。
今回の場合、 http://localhost:3000 がベースのURLになります。

今回は.envファイルで環境変数を管理します。
参考: https://qiita.com/go6887/items/2e254d31b5a4af42f813

以下のファイルをルートディレクトリに作成します。

```
$ touch .env.development
```

そして、.env.developmentのファイルに以下を記述します。

```
VUE_APP_API_BASE=http://localhost:3000
```

これで、「process.env.VUE_APP_API_BASE」が開発環境だと http://localhost:3000 を返すようになります。

試しにsrc/views/Home.vueでconsole.logを使って、process.env.VUE_APP_API_BASEの値を確認してみましょう。

```typescript
<script lang="ts">
import { defineComponent } from 'vue'
import HelloWorld from '@/components/HelloWorld.vue' // @ is an alias to /src

export default defineComponent({
  name: 'Home',
  components: {
    HelloWorld
  }
})
console.log(process.env.VUE_APP_API_BASE)
</script>
```

一度control + c でサーバーを停止させて、再度npm run serve します。(.env系のファイルはサーバー起動時に読み込まれるため)

chromeの検証ツールを開いて、consoleのタブを開くと、添付画像のようにURLが表示されているかと思います。

![スクリーンショット 2021-01-24 18.59.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/84e361b4-8b1a-13eb-ac56-d753a0a736cc.png)

問題なく.env.developmentに設定した環境変数を取得できることが確認できました。


これで、他のファイルで以下のようにES6のimport構文で読み込むと、

```typescript
import Client from '@/api/client'
```

読み込んだファイル内で以下のようにAPIをコールできます。

```typescript
Client.post('/auth_sign_in', {...})
```

今回はaxiosをラップするように書きましたが、仮にaxiosではなく別の([ky](https://github.com/sindresorhus/ky)とかjavascript標準のfetch APIとか)APIクライアントを使うことになっても、ラップしておけば修正範囲を最小限に留めることができます。

# ログイン画面を作成する

簡単なログイン画面を構築してみます。

## Login.vueの作成

src/views/Login.vueを作成します。

```terminal
$ touch src/views/Login.vue
```

```javascript
<template>
  <div>
    <label for="email">
      Email
    </label>
    <input v-model="email" id="Email" type="text" placeholder="Email">
  </div>
  <div>
    <label for="password">
      Password
    </label>
    <input v-model="password" id="password" type="password" placeholder="******************">
  </div>
  <button @click="handleLogin()">
    Sign In
  </button>
</template>

<script lang="ts">
import { defineComponent, reactive, toRefs } from 'vue'
import { login } from '@/api/auth'

export default defineComponent({
  name: 'Home',
  setup () {
    const formData = reactive({
      email: '',
      password: ''
    })

    return {
      ...toRefs(formData),
      handleLogin: async () => {
        await login(formData.email, formData.password)
          .then((res) => {
            if (res?.status === 200) {
              console.log(res)
            } else {
              alert('メールアドレスかパスワードが間違っています。')
            }
          })
          .catch(() => {
            alert('ログインに失敗しました。')
          })
      }
    }
  }
})
</script>

```

## ルーティングの設定

src/router/index.tsに/loginのルーティングを定義します。

```javascript
import Home from '@/views/Home.vue' // @記法に修正
import Login from '@/views/Login.vue' // 追加

const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {                  // 追加
    path: '/login',  // 追加
    name: 'Login',   // 追加
    component: Login // 追加
  },                 // 追加
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
  }
]
```

# ログイン・ログアウトAPIを叩く準備

次はログイン・ログアウトのAPIを叩けるようにします。

認証tokenを管理する必要があるのですが、ここではLocalStorageにtokenを保存する実装を採用します。
LocalStorageに認証用のtokenを保存することの危険性については各所で指摘されていますが、
管理するデータの機微性に応じて、保存場所を決めるべきかと思います。

## LocalStorageにTokenを保存する

まずは認証tokenをLocalStorageに保存する実装を行います。

```
$ mkdir src/utils

$ mkdir src/types

$ touch src/utils/auth-data.ts

$ touch src/types/auth.ts
```

```typescript
// src/utils/auth-data.ts

import { AuthHeaders } from '@/types/auth'

export const getAuthDataFromStorage = (): AuthHeaders => {
  return {
    'access-token': localStorage.getItem('access-token'),
    'client': localStorage.getItem('client'),
    'expiry': localStorage.getItem('expiry'),
    'uid': localStorage.getItem('uid'),
    'Content-Type': 'application/json'
  }
}

export const setAuthDataFromResponse = (authData: AuthHeaders): void => {
  if (authData['access-token'] && authData['client'] && authData['uid'] && authData['expiry']) {
    localStorage.setItem('access-token', authData['access-token'])
    localStorage.setItem('client', authData['client'])
    localStorage.setItem('uid', authData['uid'])
    localStorage.setItem('expiry', authData['expiry'])
  }
}

export const removeAuthDataFromStorage = (): void => {
  localStorage.removeItem('access-token')
  localStorage.removeItem('client')
  localStorage.removeItem('uid')
  localStorage.removeItem('expiry')
}
```

```typescript
// src/types/auth.ts

export type AuthHeaders = {
  'access-token': string | null;
  'uid': string | null;
  'client': string | null;
  'expiry': string | null;
  'Content-Type': string;
}
```

上記の関数は

「LocalStorageからtokenを取得してオブジェクトとして返す」
「引数のtokenをLocalStorageに格納する」
「LocalStorageからtokenを削除する」

を行っています。この各種関数をsrc/api/auth.tsで呼び出すようにします。

## ログイン・ログアウトのAPIを叩く関数を実装

src/api/auth.tsファイルを作成し、以下のコードを記述します。

```terminal
$ touch src/api/auth.ts
```

```typescript
// src/api/auth.ts

import Client from '@/api/client'
import { User } from '@/types/user'
import {
  getAuthDataFromStorage,
  removeAuthDataFromStorage,
  setAuthDataFromResponse
} from '@/utils/auth-data'
import { AxiosResponse, AxiosError } from 'axios'

export const login = async (email: string, password: string) => {
  return await Client.post<User>('/auth/sign_in', { email, password })
    .then((res: AxiosResponse<User>) => {
      setAuthDataFromResponse(res.headers)
      return res
    })
    .catch((err: AxiosError) => {
      return err.response
    })
}

export const logout = async () => {
  return await Client.delete('/auth/sign_out', { headers: getAuthDataFromStorage() })
    .then(() => {
      removeAuthDataFromStorage()
    })
}
```

返却される値はUser型として定義したいので、以下のファイルを作成します。

```terminal
$ touch src/types/user.ts
```

```typescript
// src/types/user.ts

export type User = {
  allow_password_change: boolean;
  email: string;
  id: string;
  image: string | null;
  nickname: string;
  provider: string;
  uid: string;
}
```

この状態で npm run serve を実行した後に http://localhost:8080/login にアクセスし、添付画像のような表示がされていればOKです。
![スクリーンショット 2021-01-24 17.29.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/ceb08733-2830-b99a-0b25-be4d6fd45b17.png)

ここまで実装できたら、画面からEmailとPasswordを入力して認証ができるかどうかをテストしてみましょう。

## 画面からログインの動作を確認

http://localhost:8080/login にアクセスして、検証ツールを開いて、Consoleにタブを合わせた状態でログインをしてみます。

[Rails編](https://qiita.com/kk-icare/items/c4cd040824c7d6b434d6)で作成したユーザーのメールアドレス(test-user+1@example.com)とパスワード(password)を入力し、Sign In ボタンをクリックしてください。

ログインに成功すると、Consoleに添付画像のようなデータが表示されるかと思います。
![スクリーンショット 2021-01-24 18.41.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/abad02f9-4f1d-6c72-81db-1ba6b6b6d4e2.png)

この値が、login関数の返り値になっています。

ApplicationタブのLocal Storageの中身を見てみましょう。
![スクリーンショット 2021-01-24 18.41.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/08a0d870-1e81-e32b-efb5-90e184af10fc.png)

setAuthDataFromResponse関数の呼び出しが正しく行われて、LocalStorageに認証に必要な情報が格納できています。

## 画面からログアウトの動作を確認

簡易的なログアウトボタンを実装してみます。
Home.vueを以下のように編集してください。

```javascript
<template>
  <button @click="handleLogout()">Logout</button>
</template>

<script lang='ts'>
import { defineComponent } from 'vue'
import { logout } from '@/api/auth'
import router from '@/router'

export default defineComponent({
  name: 'Home',
  setup () {
    const handleLogout = () => {
      logout().then(() => {
        router.push('/about')
      })
    }
    return {
      handleLogout
    }
  }
})
</script>
```

logout関数をimportし、buttonのclickイベントが発生した際にlogout関数を呼び出し、
aboutの画面に遷移するようにしています。

http://localhost:8080/ にアクセスするとログアウトボタンが表示されていると思いますので、ボタンを押してください。
ApplicationタブのLocal Storageからaccess-token等が削除されれば、ログアウト成功です。
![スクリーンショット 2021-01-24 18.48.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/393dc9cc-6b36-4da5-6519-533a0d62ad8c.png)

# おわりに

[その2](https://qiita.com/kk-icare/items/b3aca446c24e89e018c4)へ続く予定です。
次回は「投稿機能」を実装していきます。