API モードの Rails でファイルをアップロード & ダウンロードするサンプル

API モードの Rails でファイルをアップロードするためのサンプルです。
バージョンは以下のとおりです。

- Ruby 3.0.1
- Rails 6.1.3

Carrierwave を使うパターンと、 Active Storage を使うパターンを紹介します。

### Carrierwave をインストール

Carrierwave はファイルのアップロード機能を簡単に追加できるようにするための gem です。

Gemfile に `gem 'carrierwave'` を追記して、 `bundle install` を実行します。


### User モデルを作成

```console
# rails g scaffold User name:string email:string
# rails db:migrate
```

### ファイルアップロード用のモデルを作成する

```console
# rails g uploader user_image
```

`app/uploaders/user_image_uploader.rb`が作られます。

### ファイル名を格納するカラムを追加

```console
# rails g migration add_filename_to_users user_image:string
# rails db:migrate
```

User モデルに uploader を設定します。

```ruby:app/models/user.rb
class User < ApplicationRecord
  mount_uploader :user_image, UserImageUploader
end
```

### コントローラーを編集

コントローラーを以下のように編集します。

```ruby:app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :set_user, only: %i[ show update destroy ]

  # GET /users
  # GET /users.json
  def index
    @users = User.all
  end

  # GET /users/1
  # GET /users/1.json
  def show
  end

  # POST /users
  # POST /users.json
  def create
    @user = User.new(user_params)

    if @user.save
      render json: {msg: 'The image is successfully uploaded.'}
    else
      render json: {user: @user.errors}, status: unprocessable_entity
    end
  end

  # PATCH/PUT /users/1
  # PATCH/PUT /users/1.json
  def update
    if @user.update(user_params)
      render :show, status: :ok, location: @user
    else
      render json: @user.errors, status: :unprocessable_entity
    end
  end

  # DELETE /users/1
  # DELETE /users/1.json
  def destroy
    @user.destroy
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_user
      @user = User.find(params[:id])
    end

    # Only allow a list of trusted parameters through.
    def user_params
      params.permit(:name, :email, :user_image)
    end
end

```


### リクエストを送ってみる

```console
curl -X POST \
 http://localhost:3000/users \
 -F email=testing@example.com \
 -F name=test \
 -F user_image=@/Users/name/Documents/work/manjiro.jpg
```

1行にすると以下です。

```console
curl -X POST http://localhost:3000/users -F email=testing@example.com  -F name=test -F user_image=@/Users/name/Documents/work/manjiro.jpg
```

`curl` を実行すると、サーバー上の`public/uploads/user/user_image` 以下にリクエストを投げたファイルがアップロードされます。


## Active Storage によるファイルアップロード

Active Storage は Rails 5.2 から提供された公式のファイルアップロード機能です。

Active Storage を使ったファイルアップロード機能について検証していきます。

以下のコマンドで Rails に Active Storage をインストールします。

```console
# bin/rails active_storage:install
# bin/rails db:migrate
```

次に scaffold を利用して image という Active Storage 用の属性を持つ person というモデルを作成します。

```console
# bin/rails g model user username:string avatar:attachment
# bin/rails db:migrate
```

生成された person モデルを以下のように編集します。

```ruby:app/models/person.rb
class User < ApplicationRecord
  has_one_attached :avatar
  validates :username, presence: true
end
```

コントローラーを作成します。

```console
# bin/rails g controller api/users create show
```

`config/routes.rb`を以下のように書き換えます。

```ruby:config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    resources :users, only: %i[create show] do
      get :avatar, on: :member
    end
  end
end
```

`app/controllers/api/people_controller.rb`を以下のように書きます。

```ruby:app/controllers/api/users_controller.rb
class Api::UsersController < ApplicationController
  def create
    user = User.new(create_params)

    if user.save
      render json: success_json(user), status: :created
    else
      render json:error_json(user), status: :unprocessable_entity
    end
  end

  def show
    user = User.find_by(id: params[:id])

    if user.present?
      render json: success_json(user), status: :ok
    else
      head :not_found
    end
  end

  private

  def create_params
    params.require(:user).permit(:username, :avatar)
  end

  def success_json(user)
    {
      user: {
        id: user.id,
        username: user.username
      }
    }
  end

  def error_json(user)
    { errors: user.errors.full_messages }
  end
end

```

curl でリクエストを投げてみます。

```console
$ curl --include --request POST http://localhost:3000/api/users --form "user[username]=Sano Manjiro" --form "user[avatar]=@/Users/fj/Documents/work/manjiro.jpg"

HTTP/1.1 100 Continue

HTTP/1.1 201 Created
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-Download-Options: noopen
X-Permitted-Cross-Domain-Policies: none
Referrer-Policy: strict-origin-when-cross-origin
Content-Type: application/json; charset=utf-8
ETag: W/"d564049a8614c4dc929aa52b97ea8326"
Cache-Control: max-age=0, private, must-revalidate
X-Request-Id: 6819fa9e-334e-49b2-a91c-6157bb3427e2
X-Runtime: 1.534877
Content-Length: 63

{
  "user": {
    "id": 1,
    "username": "Sano Manjiro"
  }
}%
```

Sequel Ace でデータベースを見てみました。

![スクリーンショット 2021-05-23 23.07.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/888099/1e5e7f7a-89c9-7887-2745-93c21a02328a.png)

リクエストで投げた画像ファイルが `active_storage_blobs` に格納されています。

active_storage_blobs の カラム `key` と同じ名前のファイルが `/storage/ap/8q/`以下に格納されています。ファイルの実態はここにあります。

`/storage/ap/8q/`以下に格納するのはデフォルトの仕様で、「どこにファイルを格納するか？」は `config/storage.yml`で設定します。

デフォルトでは以下のようになっています。

```ruby:config/storage.yml
test:
  service: Disk
  root: <%= Rails.root.join("tmp/storage") %>

local:
  service: Disk
  root: <%= Rails.root.join("storage") %>
```

パラメーターが足りないリクエストを投げたときのエラーを試してみましょう。

```console
$ curl --include --request POST http://localhost:3000/api/users --form "user[avatar]=@/Users/fj/Documents/work/manjiro.jpg"
HTTP/1.1 100 Continue

HTTP/1.1 422 Unprocessable Entity
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-Download-Options: noopen
X-Permitted-Cross-Domain-Policies: none
Referrer-Policy: strict-origin-when-cross-origin
Content-Type: application/json; charset=utf-8
Cache-Control: no-cache
X-Request-Id: 77dd2cb4-dd7d-4bdd-8875-6460e31d052b
X-Runtime: 0.023324
Content-Length: 51

{
  "errors": [
    "Username can't be blank"
  ]
}%
```

コントローラに以下のメソッドを追加して、アップロードしたイメージをダウンロードできるようにしてみます。

```ruby:controller/users_controller.rb
  def avatar
    user = User.find_by(id: params[:id])

    if user&.avatar&.attached?
      redirect_to rails_blob_url(user.avatar)
    else
      head :not_found
    end
  end
```

user が avatar を持っているか確認してみます。

```console
$ curl --head http://localhost:3000/api/users/1/avatar
HTTP/1.1 302 Found
```

実際に avatar をダウンロードしてみます。

```console
$ curl --location http://localhost:3000/api/users/1/avatar > downloaded_avatar.png

  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   258    0   258    0     0   4031      0 --:--:-- --:--:-- --:--:--  4031
100   584    0   584    0     0   3089      0 --:--:-- --:--:-- --:--:--  3089
100 10948  100 10948    0     0  46786      0 --:--:-- --:--:-- --:--:-- 46786
```


### RSpec でテストを書く

```console
# rails g rspec:install
# bin/rails g rspec:request api/user
Running via Spring preloader in process 10803
      create  spec/requests/api/users_spec.rb
# mkdir -p spec/fixtures/files

## テスト用のイメージファイルを格納する 
# touch spec/fixtures/files/avatar.jpg
```

RSpec を書きます。

```ruby:spec/requests/api/users_spec.rb
require 'rails_helper'

RSpec.describe "Api::Users", type: :request do
  describe 'POST /api/users' do
    subject { post '/api/users', params: params }

    let(:params) {{ user: { username: username, avatar: avatar }}}
    let(:username) {'Hanagaki Takemichi'}
    let(:avatar) { fixture_file_upload('avatar.jpg') }

    context 'valid request' do
      it 'returns status created' do
        subject
        expect(response).to have_http_status :created
      end

      it 'returns a JSON response' do
        subject
        expect(JSON.parse(response.body)).to eql(
         'user' => {
           'id' => User.last.id,
           'username' => 'Hanagaki Takemichi'
         })
      end

      it 'creates a user' do
        expect { subject }.to change { User.count }.from(0).to(1)
      end

      it 'creates a blog' do
        expect { subject }.to change { ActiveStorage::Blob.count }.from(0).to(1)
      end
    end

    context 'missing username' do
      let(:username) { nil }

      it 'returns status unprocessable entity' do
        subject
        expect(response).to have_http_status :unprocessable_entity
      end

      it 'returns a JSON response' do
        subject
        expect(JSON.parse(response.body)).to eql(
         'errors' => ['Username can\'t be blank']
        )
      end

      it 'does not create a user' do
        expect { subject }.not_to change { User.count }.from(0)
      end

      it 'does not create a blob' do
        expect { subject }.not_to change { ActiveStorage::Blob.count }.from(0)
      end
    end
  end
end

```


テスト用のデータベースを作成して、 rspec を走らせます。

```console
# RAILS_ENV=test bundle exec rake db:migrate
# bundle exec rspec
```