Help us understand the problem. What is going on with this article?

[コードリーディング] 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の値を取得し、リクエストメソッドを書き換えている。

#参考文献

rakuten
楽天グループは、「イノベーションを通じて、人々と社会をエンパワーメントする」ことをミッションとしています。ユーザーや取引先企業へ満足度の高いサービスを提供するとともに、多くの人々の成長を後押しすることで、社会を変革し豊かにしていきます。「グローバル イノベーション カンパニー」であり続けるというビジョンのもと、企業価値・株主価値の最大化を図ってまいります。
https://corp.rakuten.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away