ParamsWrapperの挙動

  • rails

spec記述中に見つけた挙動

試みたこと

「POSTリクエストを受け付けてファイル出力ジョブを予約する」というAPIエンドポイントを追加したく、ほぼ同じ挙動をする FugasController をコピペしつつ HogesController を作った。エンドポイントも /api/v1/exports/hoges/api/v1/exports/fugas という感じなのだけど、この命名は RESTful APIとしてアプリケーション内のリソース名を用いたものではなく、出力されるファイル名に基づいていた。 これは今回のケースで大事な前提である。

追加した HogesController にrequest specを書きたかったので、 FugasController に対するspecをパクって書いた。

RSpec.describe 'Api::V1::Exports::Hoges', type: :request do
  describe 'POST /api/v1/exports/hoges' do
    before do
      headers['Content-Type'] = 'application/json'
      params['year'] = year
    end

    describe '正常系' do
      context 'yearが正しい西暦の場合' do
        let(:year) { 2022 }
        it '202 accepted のレスポンスが返ること' do
          subject
          expect(response).to have_http_status(:accepted)
          assert_response_schema_confirm
        end
      end
    end
  end
end

起きたこと

なぜかパラメータが足りなくて失敗した。

既存の ...fugas が飛んでくる FugasController で受け取る params は以下のような形になる。

{ "year": 2022, "fuga": {"year": 2022} }

なので、 FugasController では次のようにして params を受け取っている。

def valid_params
  params.require(:fuga).permit(:year)
end

これで valid_params[:year] は正常に受け取れているのである。

一方、新たに追加した ...hogesparams はこうなる。

{ "year": 2022, "hoge": {} }

なので、 FugasController と同じノリでパラメータを絞ると「 year がない」ということでコケてしまうのである。

あるべき姿がどうかはともかく、とりあえず既存のエンドポイントに関するコントローラはすべて同じ挙動をしているので、今回追加した FugasControllerHogesController と同じように params[:hoge][:year] で取得できるようになってほしい。

この HogeFuga の間にある挙動の差は何なのか全然わからなくて詰んだ。

原因

ParamsWrapperという存在

同僚から ParamsWrapper というモジュールの存在を教えてもらい、これはコントローラがクラス名に応じてリクエストをラップしてくれるというものだった。

たとえば、 UsersController に以下のようなリクエストを投げると

{ "name": "zoshigayan", "age": 27  }

以下のようにしてくれるというものらしい。

{ "name": "zoshigayan", "age": 27, "user": { "name": "zoshigayan", "age": 27 } }

まさにこれである。これが今回の件の鍵を握っている気がするッ。

挙動の差

ドキュメントを読んでいると、しれっと最後にすごいことが書いてあった。雑に訳すとこういう感じ。

もしラップすべきキーを指定しなかった場合、 ParamsWrapper はそのコントローラに紐づくmodelが存在するかどうかによってキーを決定します。たとえば、以下のようなコントローラの場合:

class Admin::UsersController < ApplicationController
end

これは Admin::User または User モデルが存在するなら、そのモデルをキーの特定に使います。どちらも存在しない場合、フォールバックして user をキーとして用います。

これだけだと具体的に何が起きるのか微妙に分かりづらいけど、つまり HogesController の挙動は Hoge というモデルがアプリケーション上に存在するかどうかで異なるということである。そしてこれは同僚調べで分かったことだけど、 モデルが存在した場合、モデルのプロパティ (=テーブルのカラム名) に含まれないキーはラップしてくれなくなる のである。

確かに Hoge は単なるファイル名だけど Fuga は別ドメインでモデルとしても使われている。そのため、ActiveRecord としての Fuga にプロパティとして存在しない year はラップしてもらえなかった。

解決策

とりあえず Fuga の挙動を Hoge に合わせるには、以下のようにして HogeController にラップしてほしいキーを明示すればよい。

 class Api::V1::Exports::HogesController < Api::V1::ApplicationController
+  wrap_parameters :hoge, include: [:year]
 
   def create
	   # ...
   end

こうしたら確かに HogesController でも冒頭のような形で params[:hoge][:year] を受け取ることができた。

そもそもファイル出力用APIなら ParamsWrapper を有効化しないほうがいいのではないかという気もしつつ、とりあえず既存のAPIと挙動を揃えられたのでよかったよかった。おわり。