はじめに
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 を投稿する際に認証コードを同時に入力しなければならない
要件が来たとします。 - 認証コードの検証処理は本題と関係ない為、スルーします
PostController
の post_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