Railsのルーティングで多数のHTTP OPTIONSをうまく扱う方法(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

: 本記事のようにルーティングでアスタリスク*を使うと、rails routesの出力ですべてのURLの網羅が保証されなくなります*の部分は展開なしで出力される)。大規模案件などでルーティングの見落としなどにつながる可能性もあるため、採用の際は今後の改修上のリスクなどの考慮が必要と考えられます。

Railsのルーティングで多数のHTTP OPTIONSをうまく扱う方法(翻訳)

Photo by Natalia Y on Unsplash

RailsのAPIセットアップ周りがいかに強力であっても、OPTIONSHEADといった他のHTTPメソッドのハンドラを実装しようとすると困ってしまいます。ルーティングするエンドポイントごとにこういうあまり使われないHTTPメソッドを指定するときは特にそうです。たとえばRailsのルーティングにsessionsというAPIリソースがあるとします。

namespace :api do
  namespace :v1 do
    resources :sessions
  end
end

Railsでは以下のルーティングが定義されます(ルート名やヘルパに応じて)。

GET     /api/v1/sessions(.:format)
POST    /api/v1/sessions(.:format)
GET     /api/v1/sessions/:id(.:format)
PATCH   /api/v1/sessions/:id(.:format)
PUT     /api/v1/sessions/:id(.:format)
DELETE  /api/v1/sessions/:id(.:format)

Railsでは、これ以外のマイナーなHTTPメソッド(OPTIONSHEADなど)はデフォルトですべてスキップします。しかしそれももっともです。マイナーなHTTPメソッドはめったに使われませんし、その必要が生じるとしても1つか2つのエンドポイントぐらいしかないので、既存のRailsルーティングAPIで問題は生じません。こうしたマイナーなHTTPメソッドの追加は次のように簡単にできます。

namespace :api do
  namespace :v1 do
    resources :sessions do
      collection do
        match '', via: :options, action: 'options'
      end
    end
  end
end

上で生成されるルーティングは次のようになります。

GET     /api/v1/sessions(.:format)
POST    /api/v1/sessions(.:format)
GET     /api/v1/sessions/:id(.:format)
PATCH   /api/v1/sessions/:id(.:format)
PUT     /api/v1/sessions/:id(.:format)
DELETE  /api/v1/sessions/:id(.:format)
OPTIONS /api/v1/sessions(.:format)

しかし、すべてのルーティングでHTTP OPTIONSHEADなどのハンドラが欲しいときはどうするのでしょうか?ルーティングひとつひとつにOPTIONSエンドポイントを追加して回るなど、とうていやってられません。

私たちの場合、クライアントへの通知用にAPIのリクエスト/レスポンスのJSON(hyper)schemaをHTTP OPTIONSで公開したいと思っていました。私たちが公開したいこの同じスキーマは、APIのテストにも使われるので、スキーマは(ドキュメントよりも)常に更新されます。

インターフェイスAPIを設計する

私はインターフェイスを新しく設計する場合、常にインターフェイスのAPIを最初に設計します。どんなAPIが理想的でしょうか。

class Api::V1::UsersController < ApplicationController
  before_action :authenticate_user!

  def create
    # POST /api/v1/usersのレスポンスハンドラ
  end

  def show
    # GET /api/v1/users/:idのレスポンスハンドラ
  end

  options do
   # OPTIONS /api/v1/usersのオプション本体を公開する
   # OPTIONS /api/v1/users/:idのオプション本体を公開する
  end
end

ブロックを受け取るクラスメソッドが1つあればよさそうです。最終的にそのメソッドがほとんどの静的データを扱うことになります。ブロック内部では、collection(/users)とresource(/users/:id)のルーティングを区別できる必要があります。

options do
  if is_index?
    # OPTIONS /api/v1/usersのオプション本体を公開する
  else
    # OPTIONS /api/v1/users/:idのオプション本体を公開する
  end
end

インターフェイスAPIを実装する

ありがたいことに、Railsには制限を加えながらすべてのルーティングにマッチさせる方法が用意されています。この場合、OPTIONSのHTTPメソッドを持つすべてのルートにマッチさせたいと思います。これを行うには、Railsルートに以下を追加するだけです。

match '*path', {
    controller: 'application',
    action: 'options',
    constraints: { method: 'OPTIONS' },
    via: [:options]
  }

次にapplicationコントローラで、このルーティングを扱うメソッドを定義し、URLに応じて適切なcontroller#optionsに委譲する必要があります。

def options
  # 適切なcontroller#optionsブロックに委譲する
end

今回の作業で最も面倒なのは、URLで示されたリソースをURLに基いてどのコントローラで扱うかを特定する部分です。Railsのrecognize_pathでは、関連付けられるメソッドを特定するのに2つのパラメータ(URLと、URLで使う実際のHTTPメソッド)が必要だからです。しかしこの場合は異なるHTTPメソッド(OPTIONS)を使うので、それなりに試行錯誤が必要です。

def route_details_for(url)
  [@route_details](http://twitter.com/route_details "Twitter profile for @route_details") ||= begin
    methods = [:get, :post, :put, :patch, :delete]
    method = methods[0]
    tries ||= 0
    route_details = nil
    begin
      route_details = Rails.application.routes.recognize_path(url, method: method)
      raise ActionController::RoutingError, '' if route_details[:action] == 'route_not_found'
      rescue ActionController::RoutingError => _
      method = methods[tries]
      retry unless (tries += 1) == 5
    else
      return route_details
    end
  end
end

試行錯誤の結果はそこそこシンプルになりました。URLを受け取り、それに関連付けられるコントローラとメソッドの情報をrecognize_pathで得られるまでループします。

その結果、{:controller => "api/v1/searches", :action => "create" }という形になり、関連するコントローラに変換して定数化しやすくなりました。

def controller_for(url:)
  route_details = route_details_for(url)
  name = route_details[:controller].titleize.gsub('/', '::').gsub(' ','')
  return "#{name}Controller".constantize
end

当初のAPI設計の実装の最後の部分は、関連付けられるコントローラ(controller_forで見つかります)からのオプションブロックを呼び出すことです。コントローラのこのメソッドをそのまま呼び出すこともできますが、より望ましいのはブロックのコンテキストを変更することです。それなら、ブロック内部のparamsなどに基いてリクエストコンテキストにアクセスできるようになります。これはRubyのBasicObjectメソッドであるinstance_execを使ってできます。これは、呼び出し元のコンテキストでlambdaprocやブロックを呼び出すことができます。このささやかなマジックについて詳しくはこちらをご覧ください。

def options
  return head :ok if controller_for(url: request.url).options.nil?

  return render({
    json: instance_exec(
      route_details_for(request.url),
      &controller_for(url: request.url).options
    )
  })
end

ここでは、recognize_pathから得たroute_detailsもパラメータとして渡していますが、これはブロックで必要になった場合に便利だと思って念のため渡しただけです。最終的に、当初の設計に極めて近い結果が得られました。

options do |route_details|
  if route_details.dig(:id)
    # /api/v1/usersのJSON (Hyper) schemaを公開する
  else
    # /api/v1/users/:id\のJSON (Hyper) schemaを公開する
  end
end

gemのバージョンについて

私たちのアプリでコンポーネント間コミュニケーション方法としてAPIが標準になるに連れて、このパターンがよく使われるようになりました。そこでこれをrails_http_optionsというgemにまとめました。

次の3つの手順で、好みのメタデータ(スキーマなど)をクライアントに公開できます。

  1. ApplicationControllerrails_http_optionsincludeします。これによって、HTTP OPTIONSリクエストの扱いを想定したoptionというpublicメソッドが追加され、その他にprivateメソッドもいくつか追加されます。
  2. 使いたいHTTP OPTIONSをrails_http_optionsで扱えるルーティングを追加します(必要ならconstrainsをもっと厳しくすることもできます)。
match '*path', {
  controller: 'application',
  action: 'options',
  constraints: { method: 'OPTIONS' },
  via: [:options]
}
  1. HTTP OPTIONSが意味のあるbodyを持つresourceにoptionsブロックを追加します。
options do
  {
    schemas: {
      accepts: Company.json_schema,
      returns: Company.json_schema
    },
    meta: { max_per_page: 100 }
  }
end

ブロック内では、リクエストされたリソースを指す関連スキーマを見つける方法を特定する必要があります。通常は、モデルのクラスメソッドやService Objectを使えばよいでしょう:)

関連記事

Railsのルーティングを極める(前編)

Railsのルーティングを極める (後編)

Railsのsecret_key_baseを理解する(翻訳)

RailsのCSRF保護を詳しく調べてみた(翻訳)

Ruby on RailsによるWEBシステム開発、Android/iPhoneアプリ開発、電子書籍配信のことならお任せください この記事を書いた人と働こう! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

BigBinary記事より

ActiveSupport探訪シリーズ