この記事の続きです
そういえばおとといやっとインプラントの治療が終わりました。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を使う方法はこれを参考にしました
まずは当然の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の設定なんかも必要になるはずです。