作業ログ コントローラーを作っていく(API) 2019/10/12

この記事の続きです

kenta-s.hatenadiary.jp

そういえばおとといやっとインプラントの治療が終わりました。10か月くらい掛かって、金額はトータル45万円くらいでした。なんども歯茎をほじくられて痛い思いをしました。

歯は大切にしましょう。僕は手遅れでした。


まずは要らないファイルがgenerateコマンドで作成されるのを防ぐためにconfig/application.rbに以下を追加しておきます(特にassetsはwebpackで管理します

config.generators do |g|
  g.helper false
  g.assets false
end

この記事では、JSから使うことになるAPIを作っていきます

アルファ版まではあくまでミニマムにしておきたいので、Task用とTaskApplication用の必要最低限のエンドポイントだけ作っておいて、それ以外のデータは管理画面で対応します。

というわけでこの記事では

  • GET /api/v1/tasks
  • GET /api/v1/tasks/:id
  • GET /api/v1/task_applications
  • GET /api/v1/task_applications/:id
  • POST /api/v1/task_applications
  • DELETE /api/v1/task_applications/:id

を作っていきます。

Taskからいきます

$ bundle exec rails g controller api/v1/tasks --skip-template-engine
      create  app/controllers/api/v1/tasks_controller.rb
      invoke  rspec
      create    spec/controllers/api/v1/tasks_controller_spec.rb

あ、APIのテストってrequestsなんでしたっけ、MiniTestだと普通にcontrollerのテストと区別してなかったです、、(がもしかしてあれはアンチパターンだったのか?)

まだspec/controllers/以下のファイルは一個なので-rで消してしまいます

$ rm -r spec/controllers/
$ mkdir -p spec/requests/api/v1
$ touch spec/requests/api/v1/tasks_spec.rb

indexはこれでいきます。タスクの取得は一覧も詳細もログインは必要ないです。いまのところはページネーションもなしです。

require 'rails_helper'

describe Api::V1::TasksController, type: :request do
  describe '#index' do
    before do
      FactoryBot.create_list(:task, 10)
    end

    it 'should return 10 tasks' do
      get '/api/v1/tasks'
      json = JSON.parse(response.body)

      expect(response.status).to eq(200)
      expect(json.size).to eq(10)
    end
  end
end

まずはルーティングエラー

Failures:

  1) Api::V1::TasksController#index should return 10 tasks
     Failure/Error: get '/api/v1/tasks'

     ActionController::RoutingError:
       No route matches [GET] "/api/v1/tasks"

config/routes.rb

Rails.application.routes.draw do
  devise_for :users

+  namespace :api, { format: 'json' } do
+    namespace :v1 do
+      resources :tasks, only: [:index, :show]
+    end
+  end
end

つぎはActionNotFound

Failures:

  1) Api::V1::TasksController#index should return 10 tasks
     Failure/Error: get '/api/v1/tasks'

     AbstractController::ActionNotFound:
       The action 'index' could not be found for Api::V1::TasksController
class Api::V1::TasksController < ApplicationController
+  def index
+    render json: Task.all
+  end
end

indexはできました

.

Finished in 0.09689 seconds (files took 1.83 seconds to load)
1 example, 0 failures

つぎはshowいきます。こんな感じでしょうか。

RSpec書き方忘れかけてます

  describe '#show' do
    let(:task) { FactoryBot.create(:task, title: 'foo', description: 'bar', end_at: Time.zone.local(2019, 10, 1, 9, 0)) }

    it 'should return a task' do
      get "/api/v1/tasks/#{task.id}"
      json = JSON.parse(response.body)

      expect(response.status).to eq(200)
      expect(json['title']).to eq('foo')
      expect(json['description']).to eq('bar')
      expect(json['end_at']).to eq('2019-10-01T09:00:00.000+09:00')
    end
  end

想定通りActionNotFoundエラー

Failures:

  1) Api::V1::TasksController#show should return a task
     Failure/Error: get "/api/v1/tasks/#{task.id}"

     AbstractController::ActionNotFound:
       The action 'show' could not be found for Api::V1::TasksController
class Api::V1::TasksController < ApplicationController
  def index
    render json: Task.all
  end

+  def show
+    render json: Task.find(params[:id])
+  end
end

気付いてはいましたがタイムゾーンの設定をしていませんでした

Failures:

  1) Api::V1::TasksController#show should return a task
     Failure/Error: expect(json['end_at']).to eq('2019-10-01T09:00:00.000+09:00')

       expected: "2019-10-01T09:00:00.000+09:00"
            got: "2019-10-01T09:00:00.000Z"

config/application.rb にタイムゾーンの設定を追加します

module Alder
  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 6.0

    # Settings in config/environments/* take precedence over those specified here.
    # Application configuration can go into files in config/initializers
    # -- all .rb files in that directory are automatically loaded after loading
    # the framework and any gems in your application.

    # Don't generate system test files.
    config.generators.system_tests = nil
    config.generators do |g|
      g.helper false
      g.assets false
    end
+    config.time_zone = 'Tokyo'
  end
end

パスしました。これでとりあえずshowもOKです

..

Finished in 0.13398 seconds (files took 1.85 seconds to load)
2 examples, 0 failures

ここまででApi::V1::TasksControllerはいったん完成です


つぎはApi::V1::TaskApplicationsControllerやります。

まずはindexのテストから書きます。いったんこんな感じでしょうか

require 'rails_helper'

describe Api::V1::TaskApplicationsController, type: :request do
  describe '#index' do
    let(:user1) { FactoryBot.create(:user) }
    let(:user2) { FactoryBot.create(:user) }
    let!(:task_application1) { FactoryBot.create(:task_application, user: user1, task: FactoryBot.create(:task, title: 'test task1')) }
    let!(:task_application2) { FactoryBot.create(:task_application, user: user1, task: FactoryBot.create(:task, title: 'test task2')) }
    let!(:task_application3) { FactoryBot.create(:task_application, user: user1, task: FactoryBot.create(:task, title: 'test task3')) }

    context 'when current_user is user1' do
      let(:user) { user1 }
      it 'should return 3 tasks' do
        sign_in user
        get "/api/v1/task_applications"
        json = JSON.parse(response.body)

        expect(response.status).to eq(200)
        expect(json.size).to eq(3)
      end
    end

    context 'when current_user is user2' do
      let(:user) { user2 }
      it 'should return no tasks' do
        sign_in user
        get "/api/v1/task_applications"
        json = JSON.parse(response.body)

        expect(response.status).to eq(200)
        expect(json.size).to eq(0)
      end
    end
  end
end

Requestでdeviseのsign_inを使う方法はこれを参考にしました

github.com

まずは当然のNameErrorなのでControllerを作ります

NameError:
  uninitialized constant Api::V1::TaskApplicationsController
  Did you mean?  Api::V1::TasksController

indexもどうせこんな感じなので先に書いてしまいます

class Api::V1::TaskApplicationsController < ApplicationController
  before_action :authenticate_user!
  def index
    render json: current_user.task_applications.all
  end
end

ルーティングエラーです。config/routes.rbいじります

1) Api::V1::TaskApplicationsController#index when current_user is user1 should return 3 tasks
     Failure/Error: get "/api/v1/task_applications"

     ActionController::RoutingError:
       No route matches [GET] "/api/v1/task_applications"
Rails.application.routes.draw do
  devise_for :users

  namespace :api, { format: 'json' } do
    namespace :v1 do
      resources :tasks, only: [:index, :show]
+      resources :task_applications, only: [:index, :show, :create, :delete]
    end
  end
end

パスしました。

..

Finished in 0.18528 seconds (files took 1.78 seconds to load)
2 examples, 0 failures

数しか確認してなくて心もとないので、jsonの中身も確認するように少しパワーアップします

    context 'when current_user is user1' do
      let(:user) { user1 }
      it 'should return 3 tasks' do
        sign_in user
        get "/api/v1/task_applications"
        json = JSON.parse(response.body)

        expect(response.status).to eq(200)
        expect(json.size).to eq(3)
+        expect(json.dig(0, 'task_title')).to eq('test task3')
+        expect(json.dig(1, 'task_title')).to eq('test task2')
+        expect(json.dig(2, 'task_title')).to eq('test task1')
      end
    end

となるとjbuilderを使いたい感じになったので作ります

$ cat app/views/api/v1/task_applications/index.json.jbuilder

json.array! @task_applications do |task_application|
  json.id task_application.id
  json.status task_application.status
  json.task_id task_application.task_id
  json.task_title task_application.task_title
  json.task_end_at task_application.task_end_at
end

task_title, task_end_atが使えるようにモデルでdelegateします

class TaskApplication < ApplicationRecord
  belongs_to :user
  belongs_to :task

  enum status: {
    pending: 0,
    accepted: 1,
    rejected: 2,
  }

  validates :status, presence: true
+  delegate :title, to: :task, prefix: true
+  delegate :end_at, to: :task, prefix: true
end

コントローラーも修正します。

jbuilderでtaskの属性を参照してるので、N+1が起きないようにincludesも追加しておきます

class Api::V1::TaskApplicationsController < ApplicationController
  before_action :authenticate_user!
  def index
-    render json: current_user.task_applications.all
+    @task_applications = current_user.task_applications.includes(:task).all.order(created_at: :desc)
  end
end

パスしました。これでindexはOKです。

..

Finished in 0.21488 seconds (files took 1.78 seconds to load)
2 examples, 0 failures

次はshowいきます

テストはこんな感じで

  describe '#show' do
    let(:user) { FactoryBot.create(:user) }
    let(:task_application) do
      FactoryBot.create(:task_application,
        user: user,
        status: :pending,
        task: FactoryBot.create(:task, title: 'test task1', end_at: Time.zone.local(2019, 10, 2, 11, 30)))
    end

    it 'should return a task' do
      sign_in user
      get "/api/v1/task_applications/#{task_application.id}"
      json = JSON.parse(response.body)

      expect(response.status).to eq(200)
      expect(json['status']).to eq('pending')
      expect(json['task_title']).to eq('test task1')
      expect(json['task_end_at']).to eq('2019-10-02T11:30:00.000+09:00')
    end
  end

indexと似たような感じでactionとjbuilderを用意してテストが通るようにします

...

Finished in 0.20841 seconds (files took 1.8 seconds to load)
3 examples, 0 failures

続いてcreateいきます。RSpecだとpostってどうやってテスト書くんでしたっけ?assert_differenceの便利バージョンがあった記憶があります

まずこれがパスするようにしてから、あとでexpectを追加します

  describe '#create' do
    let(:user) { FactoryBot.create(:user) }
    let(:task) { FactoryBot.create(:task) }

    it 'should return a task' do
      sign_in user
      post "/api/v1/task_applications/", params: {task_application: {task_id: task.id}}
      json = JSON.parse(response.body)

      expect(response.status).to eq(201)
    end
  end

actionはこれで。

show用のjbuilderを使ってるのが少し気持ち悪いのでファイル名変えようかとも思いましたが、いまのところたいした問題じゃあないと判断してそのまま(show)いきます

class Api::V1::TaskApplicationsController < ApplicationController
  before_action :authenticate_user!
  def index
    @task_applications = current_user.task_applications.includes(:task).all.order(created_at: :desc)
  end

  def show
    @task_application = current_user.task_applications.find(params[:id])
  end

+  def create
+    @task_application = current_user.task_applications.build(task_application_params.merge(status: :pending))
+    if @task_application.save
+      render template: "api/v1/task_applications/show", status: 201
+    else
+      render template: "api/v1/task_applications/show", status: 422
+    end
+  end

+  private

+  def task_application_params
+    params.require(:task_application).permit(:task_id)
+  end
end

これで先ほどのテストは通るようになりました

....

Finished in 0.2528 seconds (files took 1.86 seconds to load)
4 examples, 0 failures

テストをちょっとパワーアップします。RSpec版のassert_differenceは、expect {}.to change{}でしたね。

assert_differenceはなんか嫌いでした。評価したい内容を文字列で渡すのも気持ち悪いし、ネスト深くなるし、、

RSpecだときれいに書けて嬉しいです。

  describe '#create' do
    let(:user) { FactoryBot.create(:user) }
    let(:task) { FactoryBot.create(:task, title: 'test task') }

    it 'should return a task' do
      sign_in user
-       post "/api/v1/task_applications/", params: {task_application: {task_id: task.id}}
+      expect{ post "/api/v1/task_applications/", params: {task_application: {task_id: task.id}} }.to change {user.task_applications.count}.by(1)

      json = JSON.parse(response.body)

      expect(response.status).to eq(201)
+      expect(json['status']).to eq('pending')
+      expect(json['task_title']).to eq('test task')
    end
  end

俄然通ります。

....

Finished in 0.27628 seconds (files took 1.97 seconds to load)
4 examples, 0 failures

次はdestroyやります。これが最後

テストはこんな感じでいいですか?

  describe '#destroy' do
    let(:user1) { FactoryBot.create(:user) }
    let(:user2) { FactoryBot.create(:user) }
    let!(:task_application) do
      FactoryBot.create(:task_application,
        user: user1,
        status: :pending,
        task: FactoryBot.create(:task, title: 'test task1', end_at: Time.zone.local(2019, 10, 2, 11, 30)))
    end

    context "when user tries to delete his task_application" do
      let(:user) { user1 }
      it 'should delete task_application' do
        sign_in user
        expect{ delete "/api/v1/task_applications/#{task_application.id}" }.to change {user.task_applications.count}.by(-1)
        expect(response.status).to eq(204)
        expect{ task_application.reload }.to raise_error(ActiveRecord::RecordNotFound)
      end
    end

    context "when user tries to delete another user's task_application" do
      let(:user) { user2 }
      it 'should not delete task_application' do
        sign_in user
        expect{ delete "/api/v1/task_applications/#{task_application.id}" }.to raise_error(ActiveRecord::RecordNotFound)
        expect{ task_application.reload }.not_to raise_error
      end
    end
  end

actionはこれで。

  def destroy
    task_application = current_user.task_applications.find(params[:id])
    if task_application.destroy
      head :no_content
    else
      render nothing: true, status: 422
    end
  end

パスしました。これでdestroyアクションも完成です

......

Finished in 0.31002 seconds (files took 1.85 seconds to load)
6 examples, 0 failures

これでJSから使うことになるエンドポイントはそろいました。

次の記事ではログイン周りやります。見た目も触るのでwebpackの設定なんかも必要になるはずです。