トップ 追記

2019-08-10 [長年日記]

_ How to test rack middleware

ざざっとメモ。

Middlewareの種類

一口に Rack middleware と言っても目的とする動作にはいくつかバリエーションがある。

  1. request を処理する middleware
  2. response を処理する middleware
  3. その他を処理する middleware ( middleとは? )

例えば Rack::Access や Rack::Cors, OmniAuth はアプリに到達する前の request を処理する middleware であり、Rack::ETag や Rack::Deflator は response を処理するものである。ExceptionNotification などはその他を処理する middleware である。

基本的にアプリは適当でよい

例えばこんな感じだ。

  • 適当な lambda を作って名前を付ける(#callを呼べればなんでもよい)
  • rspec や minitest/spec では let でもよい

この時に大事なのは、例外を起こす lambda にするか、あるいは期待する値を返す lambda にするかなど、「気にしていることだけを実現する lambda」を雑に作ってしまってよいということだ。Rails アプリであろうがなかろうが Rack middleware のレベルでは status code と headers と body の三つが揃っているというただそれだけのものになる。

テストしたいアプリをbuildする

テストしたい middleware を use してテストしたい条件の lambda を run するアプリを build する。

Rack::Builder.new do
  use Middleware
  run Application
end

これを call すれば Rack middleware のテストは行える。

テストで気にするものについて十分なパターンを用意する
  • request を処理するなら env のパターンが豊富になる*1
  • response を処理するならアプリの lambda をいくつも用意する
  • その他の場合も恐らくアプリの lambda の準備を厚くする形になるだろう

例えば ExceptionNotification のようなものをテストしたいのであれば

Rack::Builder.new do
  use Middleware
  run lambda { |env| raise StandardError }
end

みたいなものが必要になる。

参考

Tags: Ruby Rack

*1 request の path や何らかの header について該当するかしないかのパターンを用意することになるはずだ。


2019-08-04 [長年日記]

_ もっとControllerから分離してtestableに

関心

Rails の Controller にメソッドを生やすとテストしにくい。できるだけ分けたい。*1

とりあえず思いついてたり実現できていたりすることをメモ。

Controllerの基本

まずは概念の整理から。

のうえで、どうしても Rails で残りがちなものが以下かなという気がしている。これらをどうバラすかのメモ。

※ Strong Parameters についてもいたずらに private メソッドが増えるのはよくないと感じていて、そもそも dry-validation など代替の手法があるが、validation の話はそれだけでそれなりに大きいので今回は除外。

before_actionをバラしたい

  • 副作用アリなら module へバラして mixin
    • 関連するメソッド群を一つの module に固めておく
    • 極力 controller に依存する処理を集約して単独でテストできる部分を増やすように
    • controller に依存する部分は普通に適当な controller にincludeして書けばよい
  • 副作用ナシで request を叩き落とすだけなら constraint へ

rescue_fromをバラしたい

  • V, Cのいずれにも依存しない副作用は C で rescue せずにRack middleware へ逃す
    • ActiveRecord 周りとか
  • View の layout や content に依存しない response も Rack middleware で可能
rescue_fromをclassでバラすこともできるけど

rescue_from をできるだけ testable にするために constraint のように独立した class にできないかと試行錯誤したが、できることはできるけど、あんまりメリットない気がする。せっかくなので一応書き出しておくけど。

rescue_from には block か Method オブジェクトに変換できる Symbol しか与えることができない。後者も最終的には Proc オブジェクトになるので、結局 Proc オブジェクトしか与えることができない。これはコードを追うと中で class を見て場合分けしているので避けようがない。そこで以下のような class を作ったとしても、

class Handler
  def call(exception)
  end
end

実際には以下のように Proc オブジェクトに変換して with に与える必要がある。

rescue_from Exception, with: Handler.new.method(:call).to_proc

だいぶおおげさではあるが、よほど複雑な処理をするのであればこういう書き方も可能。ただし、self が合わないのでそのままでは実行時の controller の中の値にはタッチできない。「そのままでは」がキモで、ちゃんと辿れば可能。

exception.bindings.first.receiver

とやれば実際に rescue した controller インスタンスが取得できるので、ここから request オブジェクトなどを参照できる。

まぁなかなかこんな方法使わないかなぁという気もするが、Exception Handler を class ベースにして(例えば特別なログを出力する、何らかのレポートサービスに通知するなど)継承したい実装を用意しておくというのは一つの手法としてアリかもしれない。単に rescue しただけで片付けてしまうと事実が消えてしまうが、その記録を毎度毎度ゼロから書くのもバカバカしいので。

Tags: Rails Ruby

*1 Rails は 1 Action : 1 Method になるのである程度以上の複雑さを持った場合に安易に private メソッドが増えやすい。private メソッドはテストしにくいので悪。Hanami 方式なら 1 Action : 1 Class なのでいいんだけど。


2019-08-03 [長年日記]

_ Railsはどうやって500やdebug用のエラー画面を出しているのか

関心

素の Rack middleware はただの玉ねぎ構造で、Exception は Exception で rescue しなければそのまま一番外側の middleware まで伝播して、ユーザーに適切に status code を伝えることなく果ててしまうはずである。

しかし実際には Rails などのフレームワークを使った場合、それっぽいエラー画面をユーザーに伝えることができるようになっている。

それでいて ExceptionNotification gem など、自分で use した middleware の中ではやはり例外を例外のまま拾うことができる。

ということは、通常の

Rack::Builder.new do
  use ..
  use ..
  run ..
end

とは異なる何らかの middleware 群がフレームワークによって外側に配置されているはずである。

結論

予想通り、いい具合に例外を適切な response に変換する middleware が外側の stack にいる。したがって通常のアプリや独自の rack middleware を書く際に例外は例外のまま投げっぱなしでよい。

いざコードリーディング

※ ちょっと事情があって古い Rails を読んでいるので最新のものでは細かい部分で食い違いがあるかもしれないが、基本的な構造は変わっていないはず

railtie の中で middleware を探していくと Rails::Engine にそれっぽい記述が見つかる。

  • Rails::Engine の中で default_middleware_stack を呼んでいて
  • これが ActionDispatch::MiddlewareStack.new して…途中すっ飛ばして
  • Rails::Application::DefaultMiddleware#build_stack の中でデフォルトで定義済みの middleware を use していく。これが

https://guides.rubyonrails.org/rails_on_rack.html#action-dispatcher-middleware-stack

に挙げられているもの。

この中で中盤くらいに ActionDispatch::ShowExceptions という middleware があって、こいつに ActionDispatch::PublicExceptions という別な middleware を与えてある。

default_middleware_stack.rb の以下のコードがそれである。

         middleware.use ::ActionDispatch::ShowExceptions, show_exceptions_app

通常の Rack::Builder で use している場合の Middleware.new(app, *args, &block ) とは別な形でもう一つの middleware が与えられていて、 rescue Exception した際にはこの show_exceptions_app として PublicExceptions が呼ばれるようになっている。該当部分は以下の通り。

show_exceptions.rb の中身はこう。

   def initialize(app, exceptions_app)
     @app = app
     @exceptions_app = exceptions_app
   end

   def call(env)
     @app.call(env)
   rescue Exception => exception
     if env['action_dispatch.show_exceptions'] == false
       raise exception
     else
       render_exception(env, exception)
     end
   end

そして PublicExceptions の中では

   def render_html(status)
     path = "#{public_path}/#{status}.#{I18n.locale}.html"
     path = "#{public_path}/#{status}.html" unless (found = File.exist?(path))

     if found || File.exist?(path)
       render_format(status, 'text/html', File.read(path))
     else
       [404, { "X-Cascade" => "pass" }, []]
     end
   end

こんな風に決め打ちで public/ 以下からerror 用の HTML を render している。なるほど。

Tags: Rails Ruby Rack

2019-08-02 [長年日記]

_ testable constraint

関心

Rails を利用するに当たって先日の脆弱性

Rails 4.2.11.1, 5.0.7.2, 5.1.6.2, 5.2.2.1, and 6.0.0.beta3 have been released! | Riding Rails

の対策などはいちいち controller に処理が渡る前に処理を終わらせて 404 などのエラーを返してしまいたい。実際に稼働中のアプリに脆弱性がない場合でも悪意のある bot 対策という意味ではいろいろなケースで同様のことが言える。

これは具体的には routing の constraint を利用することで実現可能である。*1

このような設計にしておくことには controller の before_action callback などで処理する手法に比べて以下のようなメリットがある。

  • action の dispatch に掛かるコストを節約できる(脆弱性目的のアクセスは過負荷になりがち)
  • controller は自分の責務に集中したコードだけを書けばよいので読み書きしやすい

ではどうやってこれを実現すればよいだろうか。

複雑なconstraintをProcで書くのは無理がある

しかし constraint を Proc で書こうとすると非常に書きにくい。単純な文字列比較などならよいが、request オブジェクトの中身を検証するようなものは

  • コードが長くなって rouitng が読みにくくなる
  • そもそもテストしにくい
    • routing のテストになるが、routing spec の DSL ではそのまま request header をセットすることができない
  • 条件を変えてテストコードを書いておきたいが、例えば constraint が外れていた場合に実際に問題が発生する(脆弱性に繋がる例外があがる)ことを検証しようとするとますます複雑になる

という問題がある。

Objectで書こう

constraint は Object と method で書くこともできる。

Rails Routing from the Outside In — Ruby on Rails Guides

これを読むと

#matches?

を呼べさえすればよい。#matches? に request が渡ってくるのでオッケーかどうかを true か false で返してあげるという寸法だ。

class Constraint
  def matches?(request)
  end
end

で new して渡しても

class Constraint
  def self.matches?(request)
  end
end

でそのまま渡しても include Singleton してもよい。

テストする場合は request object をそのまま渡せばよいので、ActionDispatch::Request や ActionDispatch::TestRequest を使えばよい。

これで routing spec の DSL が足りないといって気を揉む必要もなくなる。

条件を変える

constraint のテストだけでは constraint が与えた request に基づいて true を返すか false を返すかしか検証できない。慣れてしまえばこれだけで十分と判断できるかもしれないが、本当のことを言うと特定の条件を満たす request に対して Rails アプリがどのように反応するかもテストしたいはずである。つまり、constraint が有効な場合と無効な場合のテストが書けないと不安にならないだろうか?

constraint は基本的に routing にそのまま書くので適用されっぱなしになってしまう。Proc オブジェクトで書く場合は Proc の中で条件分岐させることができるが、Object と matches? メソッドで書く場合はそういうわけにいかない。

そこで stub out 可能なメソッドを用意しておく。こんな感じだ。

class Constraint
  def enabled?
    true
  end

  def matches?(request)
    if enabled?
      ..
    else
      true
    end
  end
end

これでテストの際には enabled? を false に stub out してあげれば、constraint が無効な場合にどんな問題が起きるかをテストできるようになった。そのうえで e2e の request を投げてあげれば、Rails アプリ全体がどのように反応するかが分かる。

余談だが、簡単に stub out できる Plain Old Object スバラシイと改めて思う。

Tags: Rails Ruby

*1 Rack middleware や Web サーバでも可能だけど response の返し方を揃えておくとか、利用しているフレームワークのそばに書いてある方が関連を理解しやすいという意味では Rails の中で処理するのが望ましそう。異論は認める。