背景
業務の中でrailsのヘルパーメソッドである「button_to」について改めて調べ直す機会があったので、今回はその記録として記述をします。
button_toとは?
button_toはRailsに組み込まれているヘルパーメソッドの一つです。button_toを使うと、指定したアクションへのフォームとボタンを簡単に生成できます。
= button_to "削除", "#", method: :delete
HTMLではどうのように生成されているかというと、下記のようにformタグで囲われた一つのフォーム要素として生成されています。
<form class="button_to" method="post" action="#">
<input type="hidden" name="_method" value="delete" autocomplete="off">
<button type="submit">削除</button><input type="hidden" name="authenticity_token"value="T9S0opNGpbhxWkdXu3mB9DQLA2nwGmORGRCHIrU_r2R6wp_qv0ypr7_0ogouh_efnlihvid3sI9Qnvi0UeiPcA" autocomplete="off">
</form>
よく見ると、謎のinput要素があります。
<input type="hidden" name="_method" value="delete" autocomplete="off">」
誤って入ってしまったのかと思いrailsのコードを見てみましたが、「method_tag = BUTTON_TAG_METHOD_VERBS.include?(method) ? method_tag(method) : "".html_safe」で確かにinputを作成していました。
def button_to(name = nil, options = nil, html_options = nil, &block)
html_options, options = options, name if block_given?
html_options ||= {}
html_options = html_options.stringify_keys
url =
case options
when FalseClass then nil
else url_for(options)
end
remote = html_options.delete("remote")
params = html_options.delete("params")
authenticity_token = html_options.delete("authenticity_token")
method = (html_options.delete("method").presence || method_for_options(options)).to_s
method_tag = BUTTON_TAG_METHOD_VERBS.include?(method) ? method_tag(method) : "".html_safe
form_method = method == "get" ? "get" : "post"
form_options = html_options.delete("form") || {}
form_options[:class] ||= html_options.delete("form_class") || "button_to"
form_options[:method] = form_method
form_options[:action] = url
form_options[:'data-remote'] = true if remote
request_token_tag = if form_method == "post"
request_method = method.empty? ? "post" : method
token_tag(authenticity_token, form_options: { action: url, method: request_method })
else
""
end
html_options = convert_options_to_data_attributes(options, html_options)
html_options["type"] = "submit"
button = if block_given?
content_tag("button", html_options, &block)
elsif button_to_generates_button_tag
content_tag("button", name || url, html_options, &block)
else
html_options["value"] = name || url
tag("input", html_options)
end
inner_tags = method_tag.safe_concat(button).safe_concat(request_token_tag)
if params
to_form_params(params).each do |param|
inner_tags.safe_concat tag(:input, type: "hidden", name: param[:name], value: param[:value],
autocomplete: "off")
end
end
html = content_tag("form", inner_tags, form_options)
prevent_content_exfiltration(html)
end
# actionview/lib/action_view/helpers/url_helper.rb
def method_tag(method)
tag(:input, type: "hidden", name: "_method", value: method, autocomplete: "off")
end
参考先リンク:
https://github.com/rails/rails/blob/main/actionview/lib/action_view/helpers/url_helper.rb#L295
本来であれば「form」と「button」があれば「必要ないのでは??」と思いますが、これがないとPUT, PATCH, DELETEのようなHTTPメソッドを送信することができないのです。
もっと詳しく説明すると、HTMLフォームは標準ではGETまたはPOSTしかサポートしていません。PUT, PATCH, DELETEのようなHTTPメソッドを送信する方法が直接的にはありません。
しかし、Railsではこれを解決するために、「メソッドオーバーライド」という仕組みを使用しています。この仕組みでは、フォームのmethod="post"のままにしておき、「_method」という隠しフィールドを使って、サーバー側に「実際にはDELETEを使用する」と伝えることができます。
実際にRackのコードで_methodの値が有効なHTTPメソッド(例:PUT, DELETE)であれば、env[REQUEST_METHOD]を上書きしています。
該当コード:
def call(env)
if allowed_methods.include?(env[REQUEST_METHOD])
method = method_override(env)
if HTTP_METHODS.include?(method)
env[RACK_METHODOVERRIDE_ORIGINAL_METHOD] = env[REQUEST_METHOD]
env[REQUEST_METHOD] = method
end
end
@app.call(env)
end
コード全体:
# frozen_string_literal: true
require_relative 'constants'
require_relative 'request'
require_relative 'utils'
module Rack
class MethodOverride
HTTP_METHODS = %w[GET HEAD PUT POST DELETE OPTIONS PATCH LINK UNLINK]
METHOD_OVERRIDE_PARAM_KEY = "_method"
HTTP_METHOD_OVERRIDE_HEADER = "HTTP_X_HTTP_METHOD_OVERRIDE"
ALLOWED_METHODS = %w[POST]
def initialize(app)
@app = app
end
def call(env)
if allowed_methods.include?(env[REQUEST_METHOD])
method = method_override(env)
if HTTP_METHODS.include?(method)
env[RACK_METHODOVERRIDE_ORIGINAL_METHOD] = env[REQUEST_METHOD]
env[REQUEST_METHOD] = method
end
end
@app.call(env)
end
def method_override(env)
req = Request.new(env)
method = method_override_param(req) ||
env[HTTP_METHOD_OVERRIDE_HEADER]
begin
method.to_s.upcase
rescue ArgumentError
env[RACK_ERRORS].puts "Invalid string for method"
end
end
private
def allowed_methods
ALLOWED_METHODS
end
def method_override_param(req)
req.POST[METHOD_OVERRIDE_PARAM_KEY] if req.form_data? || req.parseable_data?
rescue Utils::InvalidParameterError, Utils::ParameterTypeError, QueryParser::ParamsTooDeepError
req.get_header(RACK_ERRORS).puts "Invalid or incomplete POST params"
rescue EOFError
req.get_header(RACK_ERRORS).puts "Bad request content body"
end
end
end
参考先リンク:
https://github.com/rack/rack/blob/main/lib/rack/method_override.rb
まとめ
Railsのbutton_toは、フォームとボタンを簡単に生成し、HTML標準でサポートされないPUTやDELETEメソッドを送信するための「メソッドオーバーライド」が備わっています。これは隠しフィールド_methodを使用し、Rack::MethodOverrideがリクエストを適切に解釈する仕組みに基づいています。