Rails で content-type が application/json などの場合、MethodOverrideが効かない件

背景

Rails には MethodOverride の仕組みがあって、これはなにかっていうと、HTTP method が POST の場合でも _method=PUT とかそういうパラメーターを付けてリクエストしてあげると Rails のレイヤーではそのリクエストを PUT として扱ってくれるやつです。ブラウザの form が GET と POST しかサポートしてないけど疑似的に RESTfull にアプリケーションを作りたいみたいなときに重宝するやつですね。

さて、このMethodOverrideですが、content-typeが "application/x-www-form-urlencoded" か "multipart/form-data" のときにしか効いてくれません。これはまあ普通に考えてそれ以外のリクエストは Ajax によるリクエストとかなんか別の(iOSアプリだとかAndroidアプリだとか)クライアントからのリクエストであることが想定できるので、クライアントが PUT とか DELETE とかを送ってくれば良いだけの話なので、合理的と言えると思います。

しかし、その合理的な判断がちょっと困る場合が無い訳ではありません。GET と POST しか通さない邪悪なプロキシの下でインターネット接続をしなければならないことを強いられているひとびとにサービスを提供したい場合などです。この場合、Ajax であっても PUT メソッドや DELETE メソッドが使えないので、しょうがないので POST でリクエストを投げたいな、となったりします。

筋の良い解決方法

この MethodOverride を実際にやってくれてるのは Rails ではなくて Rack::MethodOverride です。実際にそいつを見てみると、_method というパラメータだけではなくて、X-HTTP-Method-Override という拡張ヘッダに PUT や DELETE を指定してあげれば良いことがわかります。まあこれが普通に「筋の良い」解決方法だと思います。

しかし、この「筋の良い」解決方法が使えない場合があります。それは拡張ヘッダを通してくれないような邪悪な(以下略)

最後の手段

というわけで、最後の手段です。既存の RackMiddleware が JSONの中身の _method を見て MethodOverride してくれないなら、そういう Middleware を作っちゃえばいいのです。ほぼ Rack::MethodOverride からのコピペだけど以下みたいな感じ。

module Rack
  class MethodOverrideJson
    HTTP_METHODS = %w(GET HEAD PUT POST DELETE OPTIONS PATCH LINK UNLINK)

    METHOD_OVERRIDE_PARAM_KEY = "_method".freeze

    def initialize(app)
      @app = app
    end

    def call(env)
      if env["REQUEST_METHOD"] == "POST" && env["CONTENT_TYPE"] == "application/json"
        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)

      body = req.body.read(req.content_length.to_i)
      req.body.rewind if req.body.respond_to?(:rewind)

      json = JSON.parse(body)
      method = json[METHOD_OVERRIDE_PARAM_KEY] rescue nil
      method.to_s.upcase
    rescue JSON::ParserError
      ""
    end
  end
end

で、この RackMiddleware を Rails に乗せてあげれば、一応これで json で _method を指定した場合でも動くようになります。

結論としては、こういう手段が必要になるのはほんとにコーナーケースであるからこんな手段取らなくていいことが多いし、ほぼコピペという罪を犯しているというのもある。あとこれ一度 req.body を read して rewind してるけど、Rails側でも同じことされるので効率もそんなによくない。ただ、まあ、一応邪悪なプロキシ環境下でも疑似 REST するという目的は達成されているので、邪悪なプロキシ環境下でこういうことをしたいひとは参考にしたら良いんじゃないでしょうか。Rails じゃなくて Rack の層で解決してるので Rails 以外の WAF にも応用できると思います邪悪だしニッチなので gem にはしません。