Railsの勉強のため、link_toの動作について調査したので備忘録。
Ruby on Rails ガイドのブログアプリケーションにも出てくる削除リンクに使われるlink_to
のmethod
とconfirm
の設定について、クリック時に確認ダイアログが表示されるのは何故か?、HTMLではDELETEメソッドは使えないのに**サーバー側でDELETEメソッドと認識されるのは何故か?**の2点を調べた。
link_toの挙動
link_toを以下のように記述すると、
<%= link_to 'Destroy', article_path(article), method: :delete, data: { confirm: 'Are you sure?' } %>
methodはdata-method
、confirmはdata-confirm
としてhtmlに変換される。
<a data-confirm="Are you sure?" rel="nofollow" data-method="delete" href="/articles/1">Destroy</a>
クライアントサイドの動作
data-confirm
とdata-method
はapplication.js
で読み込んでいるjquery-ujs
でハンドリングされる。
https://github.com/rails/jquery-ujs/blob/master/src/rails.js
まずlinkClickSelector
に指定されているa[data-confirm], a[data-method]
のクリック時にイベントが発火し、confirm
とmethod
の処理がハンドリングされる。
// Link elements bound by jquery-ujs
linkClickSelector: 'a[data-confirm], a[data-method], a[data-remote]:not([disabled]), a[data-disable-with], a[data-disable]',
・・・
$document.on('click.rails', rails.linkClickSelector, function(e) {
var link = $(this), method = link.data('method'), data = link.data('params'), metaClick = e.metaKey || e.ctrlKey;
if (!rails.allowAction(link)) return rails.stopEverything(e);
if (!metaClick && link.is(rails.linkDisableSelector)) rails.disableElement(link);
if (rails.isRemote(link)) {
if (metaClick && (!method || method === 'GET') && !data) { return true; }
var handleRemote = rails.handleRemote(link);
// Response from rails.handleRemote() will either be false or a deferred object promise.
if (handleRemote === false) {
rails.enableElement(link);
} else {
handleRemote.fail( function() { rails.enableElement(link); } );
}
return false;
} else if (method) {
rails.handleMethod(link);
return false;
}
});
data-confirm
が指定されている場合、allowAction
で確認ダイアログが表示され、OKだった場合にtrueを返す。
・・・
/* For 'data-confirm' attribute:
- Fires `confirm` event
- Shows the confirmation dialog
- Fires the `confirm:complete` event
Returns `true` if no function stops the chain and user chose yes; `false` otherwise.
Attaching a handler to the element's `confirm` event that returns a `falsy` value cancels the confirmation dialog.
Attaching a handler to the element's `confirm:complete` event that returns a `falsy` value makes this function
return false. The `confirm:complete` event is fired whether or not the user answered true or false to the dialog.
*/
allowAction: function(element) {
var message = element.data('confirm'),
answer = false, callback;
if (!message) { return true; }
if (rails.fire(element, 'confirm')) {
try {
answer = rails.confirm(message);
} catch (e) {
(console.error || console.log).call(console, e.stack || e);
}
callback = rails.fire(element, 'confirm:complete', [answer]);
}
return answer && callback;
},
data-method
が指定されておりXHR通信でない場合には、handleMethod
で指定したHTTPメソッドが_method
をキーにformの隠しパラメータとして作成されsubmitされる。
// Handles "data-method" on links such as:
// <a href="/users/5" data-method="delete" rel="nofollow" data-confirm="Are you sure?">Delete</a>
handleMethod: function(link) {
var href = rails.href(link),
method = link.data('method'),
target = link.attr('target'),
csrfToken = rails.csrfToken(),
csrfParam = rails.csrfParam(),
form = $('<form method="post" action="' + href + '"></form>'),
metadataInput = '<input name="_method" value="' + method + '" type="hidden" />';
if (csrfParam !== undefined && csrfToken !== undefined && !rails.isCrossDomain(href)) {
metadataInput += '<input name="' + csrfParam + '" value="' + csrfToken + '" type="hidden" />';
}
if (target) { form.attr('target', target); }
form.hide().append(metadataInput).appendTo('body');
form.submit();
サーバーサイドのハンドリング
HTMLの仕様ではGETとPOSTしかリクエストできないため、Railsでは隠しパラメータとして_method
にPUTやDELETEを指定している。
サーバーがリクエストを受信したのち、_method
が指定されている場合にはRailsのHTTP通信のインターフェースになっているRackによってHTTPメソッドを上書きしている。
Rack::MethodOverride
- params[:_method]が存在するときに、(HTTPの)メソッドを上書きます。HTTPのPUTメソッド、DELETEメソッドを実現するためのミドルウェアです。
GitHubのmethod_override.rbを見ると以下のようになっている。
module Rack
class MethodOverride
HTTP_METHODS = %w[GET HEAD PUT POST DELETE OPTIONS PATCH LINK UNLINK]
METHOD_OVERRIDE_PARAM_KEY = "_method".freeze
HTTP_METHOD_OVERRIDE_HEADER = "HTTP_X_HTTP_METHOD_OVERRIDE".freeze
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]
method.to_s.upcase
end
private
def allowed_methods
ALLOWED_METHODS
end
def method_override_param(req)
req.POST[METHOD_OVERRIDE_PARAM_KEY]
rescue Utils::InvalidParameterError, Utils::ParameterTypeError
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
req.POST
の部分はrequest.rbに以下のように記述されていて、
# Returns the data received in the request body.
#
# This method support both application/x-www-form-urlencoded and
# multipart/form-data.
def POST
if get_header(RACK_INPUT).nil?
raise "Missing rack.input"
elsif get_header(RACK_REQUEST_FORM_INPUT) == get_header(RACK_INPUT)
get_header(RACK_REQUEST_FORM_HASH)
elsif form_data? || parseable_data?
unless set_header(RACK_REQUEST_FORM_HASH, parse_multipart)
form_vars = get_header(RACK_INPUT).read
# Fix for Safari Ajax postings that always append \0
# form_vars.sub!(/\0\z/, '') # performance replacement:
form_vars.slice!(-1) if form_vars[-1] == ?\0
set_header RACK_REQUEST_FORM_VARS, form_vars
set_header RACK_REQUEST_FORM_HASH, parse_query(form_vars, '&')
get_header(RACK_INPUT).rewind
end
set_header RACK_REQUEST_FORM_INPUT, get_header(RACK_INPUT)
get_header RACK_REQUEST_FORM_HASH
else
{}
end
end
set_header
でrequest bodyで受け取った内容を読み込み、RACK_REQUEST_FORM_HASH
というキーで保存したハッシュを返却している。
Rack::MethodOverride#callでreq.POST
から取得した_method
の値を取得し、リクエストメソッドを書き換えている。
#参考文献