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

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

[Ruby on Rails ガイド](https://railsguides.jp/getting_started.html)のブログアプリケーションにも出てくる削除リンクに使われる`link_to`の`method`と`confirm`の設定について、**クリック時に確認ダイアログが表示されるのは何故か?**、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-confirm`と`data-method`は`application.js`で読み込んでいる`jquery-ujs`でハンドリングされる。

[https://github.com/rails/jquery-ujs/blob/master/src/rails.js](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メソッドを上書きしている。

- [Rails と Rack](https://railsguides.jp/rails_on_rack.html#%E5%8F%82%E8%80%83%E8%B3%87%E6%96%99)

> Rack::MethodOverride
> - params[:_method]が存在するときに、(HTTPの)メソッドを上書きます。HTTPのPUTメソッド、DELETEメソッドを実現するためのミドルウェアです。

GitHubの[method_override.rb](https://github.com/rack/rack/blob/master/lib/rack/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](https://github.com/rack/rack/blob/master/lib/rack/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`の値を取得し、リクエストメソッドを書き換えている。

＃参考文献

- [link_to に :method => :delete を指定した時の動作](http://d.hatena.ne.jp/spitfire_tree/20120323/1332477590)
- [RailsでjQueryを読み込まずにmethod: :deleteのリンクを実現する（Routing Error No route matches [GET] ... の対処法）](https://qiita.com/y-temp4/items/2d50feb3ff0d65acdf67)
- [[Ruby]Rack::MethodOverrideを少しいじった話](http://d.hatena.ne.jp/rightgo09_ruby/20130830/p1)