Posted at

[コードリーディング] link_toの:methodと:confirmの挙動

More than 1 year has passed since last update.

Railsの勉強のため、link_toの動作について調査したので備忘録。

Ruby on Rails ガイドのブログアプリケーションにも出てくる削除リンクに使われるlink_tomethodconfirmの設定について、クリック時に確認ダイアログが表示されるのは何故か?、HTMLではDELETEメソッドは使えないのにサーバー側でDELETEメソッドと認識されるのは何故か?の2点を調べた。


link_toの挙動

link_toを以下のように記述すると、


index.html.erb

<%= link_to 'Destroy', article_path(article), method: :delete, data: { confirm: 'Are you sure?' } %>


methodはdata-method、confirmはdata-confirmとしてhtmlに変換される。


index.html

<a data-confirm="Are you sure?" rel="nofollow" data-method="delete" href="/articles/1">Destroy</a>



クライアントサイドの動作

data-confirmdata-methodapplication.jsで読み込んでいるjquery-ujsでハンドリングされる。

https://github.com/rails/jquery-ujs/blob/master/src/rails.js

まずlinkClickSelectorに指定されているa[data-confirm], a[data-method]のクリック時にイベントが発火し、confirmmethodの処理がハンドリングされる。

// 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の値を取得し、リクエストメソッドを書き換えている。

#参考文献