LoginSignup
20
15

More than 5 years have passed since last update.

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

Posted at

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

#参考文献

20
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
15