Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

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

概要

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

画像は元記事からの引用です。


  • 初版公開: 2017/12/25
  • 更新: 2023/06/20

編集部注

本記事のようにルーティングでアスタリスク*を使うと、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保護を詳しく調べてみた(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。