Rails APIモード + devise_token_auth + Vue.js 3 で認証機能付きのSPAを作る(ファイル投稿編)

# はじめに

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


前回: [Rails APIモード + devise_token_auth + Vue.js 3 で認証機能付きのSPAを作る(Navigation Guard編)](https://qiita.com/kk-icare/items/6389ab6ff83f859d0e84)

今回: [Rails APIモード + devise_token_auth + Vue.js 3 で認証機能付きのSPAを作る(RequestSpec編)](https://qiita.com/kk-icare/items/20a180f7209ed8f4c485)

# ActiveStorageの導入

今回はファイルの保存をRails5.2系以降から使えるようになったActiveStorageを用いて実現してみようと思います。

## ActiveStorageとは？

Railsガイドの説明を引用します。

>Active StorageとはAmazon S3、Google Cloud Storage、Microsoft Azure Storageなどの クラウドストレージサービスへのファイルのアップロードや、ファイルをActive Recordオブジェクトにアタッチする機能を提供します。

> 出典: https://railsguides.jp/active_storage_overview.html

S3やGCSに容易に保存を可能にするGemも提供されています。

https://github.com/aws/aws-sdk-ruby

https://github.com/googleapis/google-cloud-ruby/tree/master/google-cloud-storage

https://github.com/Azure/azure-storage-ruby

今回は本番環境用のストレージに保存する手順については説明を割愛させて頂きます。

## 導入コマンドの実行

ActiveStorageを導入するには、以下のコマンドを実行する必要があります。

```terminal
$ bundle exec rails active_storage:install

$ bundle exec rails db:migrate
```

active_storage:install を実行すると、ファイルを保存するためのテーブルを定義するmigrationファイルが作成されます。

テーブル構成は以下のように定義されます。(ReleaseNotesテーブルはActiveStorageと関連付けを行ったテーブルです。今回の実装には必要ありません)

![active_storage.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/bcba98f9-36f6-d9fe-6e18-615511251309.png)

## ファイル保存のattrを定義 

ActiveStorageを用いてファイルを保存するには、has_one_attachedメソッドまたはhas_many_attachedメソッドを用いる必要があります。

has_one_attachedメソッドはファイルを1つだけ保存を可能にするメソッドで、
has_many_attachedメソッドはファイルを複数保存できるようにするメソッドです。

今回はhas_one_attachedメソッドの方を用いて実装を行います。

Postモデルに以下の記述を追加してください。

```ruby
class Post < ApplicationRecord
  belongs_to :user

  has_one_attached :icatch # 追加
end
```

これで、icatch属性がPostインスタンスに定義され、読み取りと書き込みが可能になります。

# 投稿機能APIの修正

ファイルを保存するためにcontrollerを修正する必要がありますので、posts_controller.rbに手を入れます。

## controller内でファイルを保存できるように修正

以下のようにcontrollerを修正をしてください。

```ruby
# app/controllers/posts_controller.rb

  # N+1回避のために以下の修正を加える
  def index
    @posts = Post.eager_load(:user).with_attached_icatch
  end

  # 以下を修正
  def post_params
    params.permit(:title, :body, :icatch).merge(user: current_user)
  end
```

permitメソッドに has_one_attachedで定義した :icatch を指定することで、createアクション内でPostインスタンスを保存するときにファイルも一緒に保存できるようになります。

## viewで画像のurlを返却できるように修正

ファイルを保存しただけではフロントにデータを返すことができません。
今回は画像データが保存されると仮定して、画像のURLを返すように実装します。

```ruby
class Post < ApplicationRecord
  # 以下のヘルパーを追加
  include Rails.application.routes.url_helpers

  belongs_to :user

  has_one_attached :icatch

  # 以下メソッドの追加
  def icatch_url
    return nil unless icatch.attached?

    url_for(icatch)
  end
end
```

このままだと、icatch_urlメソッドの実行時に以下の例外が発生しますので、
config/environments/development.rbでdefault_url_optionsを設定します。

```ruby
# 例外
Missing host to link to! Please provide the :host parameter
```

```ruby
# config/environments/development.rb

  # Set url_helpers for url_for methods
  Rails.application.routes.default_url_options[:host] = 'localhost'
  Rails.application.routes.default_url_options[:port] = 3000
```

この状態でicatch_urlメソッドの返り値をコンソールで確認してみます。

まずは適当な画像を添付したレコードを生成してください。

```terminal
$ mkdir db/seed_data

$ mv 適当な画像 db/seed_data

$ rails c

$ post = Post.first

$ post.icatch.attach(io: File.open(Rails.root + 'db/seed_data/適当な画像'), filename: '適当な画像')

# 後ほど使うのでsaveします
$ post.save!

$ Post.first.icatch_url
=> "http://localhost:3000/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBDZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--59a7d22bb43b6ee92bcb55321a0b1a2b1c622cdd/適当な画像"
```

上記のような返り値が返ればOKです。

フロントにicatch_urlを返却するために、app/views/_post.json.jbuilderに以下を追加してください。

```ruby
json.extract! post, :id, :title, :body, :created_at, :icatch_url # 追加
```

## 保存できる拡張子の制限を設定

今のままだとどんな拡張子のファイルでも保存ができてしまうので、保存できる拡張子を制限できるGemを導入します。

```ruby
# In Your Gemfile
gem 'activestorage-validator'


$ bundle install
```

https://github.com/aki77/activestorage-validator

ActiveStorageにはvalidationの機能がデフォルトで備わっていないため、上記のGemを用いています。

app/models/post.rbに以下を追加してください。

```ruby
  validates :icatch, blob: { content_type: ['image/png', 'image/jpg', 'image/jpeg',]}
```

content_typeにMIME-TYPEを指定することでバリデーションが可能になります。

# 投稿画面コンポーネントの修正

ここからVue.js側の修正を行なっていきます。

## formDataオブジェクトを用いた画像投稿

画像の投稿を行うためにJavaScriptのFormDataオブジェクトを用います。

https://developer.mozilla.org/ja/docs/Web/API/FormData

src/views/NewPost.vueを以下のように編集してください。

```javascript
<template>
  <div class="flex items-center h-screen w-full bg-teal-lighter">
    <div class="w-full bg-white rounded shadow-lg p-8 m-4">
      <h1 class="block w-full text-center text-grey-darkest mb-6">New Post</h1>
      <div class="flex flex-col mb-4">
        <label class="mb-2 font-bold text-lg text-grey-darkest" for="title">Title</label>
        <input v-model='title' class="border py-2 px-3 text-grey-darkest" type="text" name="first_name" id="first_name">
      </div>
      <div class="flex flex-col mb-4">
        <label class="mb-2  font-bold text-lg text-grey-darkest" for="body">Body</label>
        <textarea v-model='body' class="border py-2 px-3 text-grey-darkest" name="body" id="body"></textarea>
      </div>
      <div class="flex flex-col mb-4">
        <label class="mb-2 font-bold text-lg text-grey-darkest" for="icatch">iCatch</label>
        <input @change="setIcatch($event)" class="border py-2 px-3 text-grey-darkest" type="file">
      </div>
      <button @click='handleCreatePost()' class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 uppercase text-lg mx-auto rounded" type="submit">Create Post</button>
    </div>
  </div>
</template>

<script lang="ts">
import { createPost } from '@/api/post'
import router from '@/router'
import { defineComponent, reactive, toRefs } from 'vue'
export default defineComponent({
  name: 'NewPost',
  setup () {
    const postData = reactive({
      title: '',
      body: ''
    })
    const formData = new FormData()
    const setIcatch = (e: Event) => {
      e.preventDefault()
      if (e.target instanceof HTMLInputElement && e.target.files) {
        formData.append('icatch', e.target.files[0])
      }
    }
    return {
      ...toRefs(postData),
      setIcatch,
      handleCreatePost: async () => {
        formData.append('title', postData.title)
        formData.append('body', postData.body)
        await createPost(formData)
          .then(() => {
            router.push('/posts')
          })
      }
    }
  }
})
</script>
```

ファイル選択イベントがあった場合にsetIcatch関数を呼び出すようにして、formDataオブジェクトにアップロードされた画像を入れるようにしています。

```html
<input @change="setIcatch($event)" class="border py-2 px-3 text-grey-darkest" type="file">
```

このままではcreatePost関数を叩くときに例外が起きてしまいますので、修正します。

## 投稿APIに渡す引数の修正

src/api/post.tsを以下のように修正します。

```typescript
import Client from '@/api/client'
import {
  getAuthDataFromStorage,
  getAuthDataFromStorageWithFormData
} from '@/utils/auth-data'

export const getPosts = async () => {
  return await Client.get('/posts', { headers: getAuthDataFromStorage() })
    .then((response) => {
      return response.data
    })
    .catch((err) => {
      console.log(err)
    })
}

export const getPost = async (id: string) => {
  return await Client.get(`/posts/${id}`, { headers: getAuthDataFromStorage })
    .then((response) => {
      return response.data
    })
}

export const createPost = async (formData: any) => {
  return await Client.post(
    '/posts', formData,
    {
      headers: getAuthDataFromStorageWithFormData()
    }
  )
    .then((response) => {
      return response.data
    })
}
```

formData型の値を扱うため、postForRequest型は削除していきます。

```typescript
// src/types/post.ts

export type Post = {
  title: string;
  body: string;
  userName: string;
  createdAt: string;
  icatchUrl: string; // 追加
}

// 以下を削除
export type PostForRequest = Pick<Post, 'title' | 'body'>
```

```typescript
// src/api/post.ts

import Client from '@/api/client'

// PostForRequestを削除
import { Post } from '@/types/post'

// PostForRequest型からFormData型に修正
export const createPost = async (formData: FormData) => {
  return await Client.post(
    '/posts', formData,
    {
      headers: getAuthDataFromStorageWithFormData()
    }
  )
    .then((res: AxiosResponse<Post>) => {
      return res.data
    })
}
```

multipart/form-data形式でデータ送信を行うために、src/utils/auth-data.tsに以下の関数を追加します。

```typescript
export const getAuthDataFromStorageWithFormData = (): AuthHeaders => {
  return {
    'access-token': localStorage.getItem('access-token'),
    client: localStorage.getItem('client'),
    expiry: localStorage.getItem('expiry'),
    uid: localStorage.getItem('uid'),
    'Content-Type': 'multipart/form-data'
  }
}
```

getAuthDataFromStorageとの違いは、Content-Typeに指定する項目がapplication/jsonではなく、multipart/form-dataとしている点です。
画像を投稿する場合には getAuthDataFromStorageWithFormData 関数の方を用いるようにします。

# 投稿一覧コンポーネントの修正

最後に画像を表示するように修正を行います。

```vuejs
// src/components/AppPost.vue

<template>
<div class="rounded overflow-hidden shadow-lg pt-8 mr-8">
  <img :src="post.icatch_url">
  <div class="font-bold text-xl mb-2">{{post.title}}</div>
  <p class="text-grey-darker text-base">
    {{ post.body }}
  </p>
</div>

</template>

<script lang="ts">
import { Post } from '@/types/post'
import { defineComponent, PropType } from 'vue'

export default defineComponent({
  name: 'AppPost',
  props: {
    post: {
      type: Object as PropType<Post>,
      required: true
    }
  }
})
</script>

<style scoped>
</style>
```

この状態で一通り動作確認を行なってみます。

ログインした後、 http://localhost:8080/posts に アクセスして、先ほどsave!した画像が表示されていれば問題なく動作しているかと思います。

![スクリーンショット 2021-04-27 22.53.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/f81f4886-9eff-7bbb-0b84-bd42557742dd.png)

投稿機能も試してみましょう。

![スクリーンショット 2021-04-27 22.54.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/5eedbb21-546e-564a-6a4a-c5bb8f04ca43.png)

![スクリーンショット 2021-04-27 22.53.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/42879bf7-a983-87af-8e3f-6c7356e00ba2.png)

画像のリサイズやレイアウト等が未完成ですが、最低限の画像アップロード & 表示機能ができたかと思います。

# まとめ

次回はRspecを実装する予定です。