編集部注
本記事のようにルーティングでアスタリスク*
を使うと、rails routes
の出力ですべてのURLの網羅が保証されなくなります(*
の部分は展開なしで出力されます)。大規模案件などでルーティングの見落としなどにつながる可能性もあるため、採用の際は今後の改修上のリスクなどの考慮が必要と考えられます。
Railsのルーティングで多数のHTTP OPTIONSをうまく扱う方法(翻訳)
RailsのAPIセットアップ周りがいかに強力であっても、OPTIONS
やHEAD
といった他の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メソッド(OPTIONS
やHEAD
など)はデフォルトですべてスキップします。しかしそれももっともです。マイナーな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 OPTIONS
やHEAD
などのハンドラが欲しいときはどうすればよいでしょうか?ルーティングひとつひとつに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
を使ってできます。これは、呼び出し元のコンテキストでlambda
やproc
やブロックを呼び出すことが可能です。このささやかなマジックについて詳しくは過去記事をご覧ください。
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つの手順で、好みのメタデータ(スキーマなど)をクライアントに公開できます。
ApplicationController
にrails_http_options
をinclude
します。これによって、HTTP OPTIONSリクエストの扱いを想定したoption
というpublicメソッドが追加され、その他にprivateメソッドもいくつか追加されます。- 使いたいHTTP OPTIONSを
rails_http_options
で扱えるルーティングを追加します(必要ならconstrains
をもっと厳しくすることもできます)。
match '*path', {
controller: 'application',
action: 'options',
constraints: { method: 'OPTIONS' },
via: [:options]
}
- HTTP OPTIONSが意味のあるbodyを持つresourceに
options
ブロックを追加します。
options do
{
schemas: {
accepts: Company.json_schema,
returns: Company.json_schema
},
meta: { max_per_page: 100 }
}
end
ブロック内では、リクエストされたリソースを指す関連スキーマの探索方法を考える必要があります。通常は、モデルのクラスメソッドやService Objectを使えばよいでしょう:)
概要
原著者の許諾を得て翻訳・公開いたします。
画像は元記事からの引用です。