3
0

More than 3 years have passed since last update.

Rails の wrap_parameters は、controller からモデルを推測できる場合そのモデルにあるプロパティしか受け付けられない

Posted at

はじめに

Rails の wrap_parameters の動きに想定外なところがあってハマりました。原因が分かったので、他の人が同じところにハマらないように、共有します。

先に結論

wrap_parameters の処理は、controller からモデルが推測された場合、そのモデルにあるカラム のみ ラッパーする。

# Post モデルに title と body カラムのみとする(created_at, updated_at もあるが本題と関係ない)
# controller で下記のコードがある場合
def post_params
  params.require(:post).permit(:title, :body, :auth_code)
end

# Post モデルに auth_code カラムがないため、実際のリクエストに auth_code 項目が送信された場合は、下記のようになる
# オリジナルパラメータに auth_code 項目ありますが、ラッパーされた部分には auth_code 項目がありません!!!
...
Processing by PostsController#create as JSON
  Parameters: {"title"=>"hello world", "body"=>"最初の記事", "auth_code"=>"[FILTERED]", "post"=>{"title"=>"hello world", "body"=>"最初の記事" }
...

再現してみる

まずベース部分を用意しておく

$ rails -v
Rails 6.1.3.2

$ ruby -v
ruby 2.7.3p183 (2021-04-05 revision 6847ee089d) [x86_64-darwin20]

$ rails new wrap-params
$ cd wrap-params
$ rails g scaffold post title body
$ rails db:migrate
$ rails s

ここまでできたら、下記 curl コマンドが正常に投稿できるはずです。

$ curl -X POST -H 'Content-Type: application/json'  localhost:3000/posts.json -d '{"title":"title1","body":"body1"}'
{"id":3,"title":"title1","body":"body1","created_at":"2021-08-08T11:29:18.659Z","updated_at":"2021-08-08T11:29:18.659Z","url":"http://localhost:3000/posts/3.json"}

auth_code を追加してみる

  • 仮に Post を投稿する際に認証コードを同時に入力しなければならない 要件が来たとします。
  • 認証コードの検証処理は本題と関係ない為、スルーします

PostControllerpost_params メソッドに :auth_code を追加して、 create または update アクションで post_params[:auth_code] で取得してみると、ずっと空になっています。

def create
  puts "auth_code: #{post_params[:auth]}"
  @post = Post.new(post_params)
  ...
end
...
def post_params
  params.require(:post).permit(:title, :body, :auth_code)
end
# auth_code を追加してリクエストする
$ curl -X POST -H 'Content-Type: application/json'  localhost:3000/posts.json -d '{"title":"title1","body":"body1","auth_code":"123456"}'

# Rails の log
Started POST "/posts.json" for ::1 at 2021-08-09 20:17:20 +0900
   (0.1ms)  SELECT sqlite_version(*)
Processing by PostsController#create as JSON
  Parameters: {"title"=>"title1", "body"=>"body1", "auth_code"=>"123456", "post"=>{"title"=>"title1", "body"=>"body1"}}
Can't verify CSRF token authenticity.
auth_code:
  TRANSACTION (0.0ms)  begin transaction
  ↳ app/controllers/posts_controller.rb:31:in `block in create'
  Post Create (0.7ms)  INSERT INTO "posts" ("title", "body", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["title", "title1"], ["body", "body1"], ["created_at", "2021-08-09 11:17:20.465628"], ["updated_at", "2021-08-09 11:17:20.465628"]]
  ↳ app/controllers/posts_controller.rb:31:in `block in create'
  TRANSACTION (0.8ms)  commit transaction
  ↳ app/controllers/posts_controller.rb:31:in `block in create'
  Rendering posts/show.json.jbuilder
  Rendered posts/_post.json.jbuilder (Duration: 0.2ms | Allocations: 111)
  Rendered posts/show.json.jbuilder (Duration: 0.5ms | Allocations: 292)
Completed 201 Created in 9ms (Views: 1.3ms | ActiveRecord: 1.7ms | Allocations: 4807)

実行結果は、オリジナルの parmas に auth_code がありますが、wrap_parameters でラッパーされている部分には auth_code 項目がない!!!

原因

wrap_parameters のデフォルトの設定の場合は、

  • controller 名からモデルクラスを推測してみている
# https://github.com/rails/rails/blob/main/actionpack/lib/action_controller/metal/params_wrapper.rb#L156-L172
def _default_wrap_model
  return nil if klass.anonymous?
  model_name = klass.name.delete_suffix("Controller").classify

  begin
    if model_klass = model_name.safe_constantize
      model_klass
    else
      namespaces = model_name.split("::")
      namespaces.delete_at(-2)
      break if namespaces.last == model_name
      model_name = namespaces.join("::")
    end
  end until model_klass

  model_klass
end
  • モデルクラスが推測できたら、 attribute_names メソッドが定義されている、かつ、値が空ではない場合、その値を include に設定する
# https://github.com/rails/rails/blob/main/actionpack/lib/action_controller/metal/params_wrapper.rb#L109-L127
if m.respond_to?(:attribute_names) && m.attribute_names.any?
  self.include = m.attribute_names

  if m.respond_to?(:stored_attributes) && !m.stored_attributes.empty?
    self.include += m.stored_attributes.values.flatten.map(&:to_s)
  end

  if m.respond_to?(:attribute_aliases) && m.attribute_aliases.any?
    self.include += m.attribute_aliases.keys
  end

  if m.respond_to?(:nested_attributes_options) && m.nested_attributes_options.keys.any?
    self.include += m.nested_attributes_options.keys.map do |key|
      (+key.to_s).concat("_attributes")
    end
  end

  self.include
end
  • wrap_pamameters の include オプションがある場合、その内容だけラッパーするようになっています

上記によって、モデルクラスにあるプロパティだけラッパーされるようになるわけです。

まとめ

簡単な処理だと思っていた wrap_parameters は、意外にいろいろやっていますね。

補足

controller の post_params メソッドの処理にそのまま auth_code を追加するところがキレイじゃないとのご意見があると思いますが、ごもっともです。
ただ、「設計しすぎないように」するため、今回の例ですと Post モデルにない項目が本当に auth_code しかないので、API のみの場合最終的に下記になるのでこれで良いかと思います。
Post モデルにない項目または処理がもっと増えたら、FormObject を作るほうがキレイだと思います。

def create
  unless validate_auth_code?(params[:auth_code])
    head :bad_request
    return
  end

  @post = Post.new(post_params)
  if @post.save
    render :show, status: :created, location: @post
  else
    render json: @post.errors, status: :unprocessable_entity
  end
end

参考リンク

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0