Rails5.2でJSONパースエラーをJSON形式で返す

# なんの話？

Ruby on Railsはエラーが起きたときに、HTMLの人間向きの親切な形式でレスポンスを返してくれます。人間がブラウザを使って開発しているときは、エラーの内容が理解しやすい便利な機能です。プログラム向けのAPIのレスポンスとしてはHTMLは扱いにくい不便な形式です。

APIサーバーを作っているときは、レスポンスをJSON形式で返したいです。JSONであれば大抵の言語で簡単にパースできます。Ruby on Railsにおいて、通常のレスポンスの形式は、コントローラーでJSON形式を指定すればJSON形式になります。例えば、次のようにです。

```rb
def show
 render :json => {:name => "mya name"}
end
```

しかし、エラーレスポンス、特にJSONパースに失敗したときのエラーレスポンスはHTMLのままで、JSON形式で返せません。これは前述のRailsのHTMLで親切なエラーレスポンスを返す機能です。人間向きではありますが、API向きではありません。

## 既知の知見
古くは[Catching Invalid JSON Parse Errors with Rack Middleware](https://thoughtbot.com/blog/catching-json-parse-errors-with-custom-middleware)で、日本語の情報では[Rails で JSON のリクエストパラメータがパース出来なかった場合の対応 - Qiita](https://qiita.com/kenchan0130/items/b7ecc5a9706a3675d863)で、対応方法が知られています。

要約すると、JSONパースエラーはRackミドルウェアの階層で処理されているので、Railsアプリケーションでは変更できません。適切なレスポンスを返すRackミドルウェアを追加して対応します。次のようなRackミドルウェアです。

```config/initializers/rescue_json_parse_errors.rb
class RescueJsonParseErrors
  def initialize(app)
    @app = app
  end

  def call(env)
    begin
      @app.call(env)
    rescue ActionDispatch::ParamsParser::ParseError => _e
      return [
        400, { 'Content-Type' => 'application/json' },
        [{ error: 'There was a problem in the your JSON' }.to_json]
      ]
    end
  end
end
```

ただし、この対応ではRails 5.2では期待通りに動きません。

# 原因

Rails 5.2の変更に対応する必要があります。

## Rails 5.2の変更

1. ActionDispatch::ParamsParser がRackミドルウェアでなくなった
2. config.middleware.insert_before にミドルウェアを文字列で指定できなくなった
3. JSONパースエラーの例外がActionDispatch::ParamsParser::ParseError でなくなった

以上の差分に対応すると、Rails 5.2でも、JSONパースエラーをJSON形式で返せます。

# 対応方法

## 最終型
最初に最終型を示します。次の2つのファイルを追加します。

```config/initializers/rescue_json_parse_errors.rb
Rails.application.config.middleware.insert_before Rack::Head, Middleware::RescueJsonParseErrors
```

```app/lib/middleware/rescue_json_parse_errors.rb
module Middleware
  class RescueJsonParseErrors
    def initialize(app)
      @app = app
    end

    def call(env)
      @app.call(env)
    rescue ActionDispatch::Http::Parameters::ParseError
      raise unless %r{application/json}.match?(env['HTTP_ACCEPT'])

      [
        400, { 'Content-Type' => 'application/json' },
        [{ status: 400, error: 'You submitted a malformed JSON.' }.to_json]
      ]
    end
  end
end
```

ミドルウェアの配置場所は`app/lib/middleware`でなくても構いません。自動読込されるパスで、Rackミドルウェアであることがわかりやすいディレクトリを選択しました。

## 対応 1. ActionDispatch::ParamsParser がRackミドルウェアでなくなった
結論： `ActionDispatch::ParamsParser`の代わりに`Rack::Head`を指定します。

```config/apprication.rb
module MyApp
  class Application < Rails::Application
    config.middleware.insert_before ActionDispatch::ParamsParser, 'RescueJsonParseErrors'
  end
end
```
の部分です。このまま実行すると次のエラーが発生します。

```sh
rails aborted!
NameError: uninitialized constant ActionDispatch::ParamsParser
```

Rails 5.1から[ActionDispatch::ParamsParserはActionDispatch::Http::Parametersに変わっています](https://github.com/rails/rails/commit/b3d41eae4b138d6c9d38bd9c1cbe033802c0e21d)。またミドルウェアでもなくなっています。

`rails middleware`コマンドで実行すると

```sh
~ rails middleware
use Rack::Cors
use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use Rack::MethodOverride
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Sprockets::Rails::QuietAssets
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use ActionDispatch::DebugExceptions
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use ActiveRecord::Migration::CheckPending
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use ActionDispatch::ContentSecurityPolicy::Middleware
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
use Rack::TempfileReaper
```

それらしいミドルウェアは存在しません。

[ruby on rails - Catching Invalid JSON Parse Errors with Rack Middleware - Stack Overflow](https://stackoverflow.com/questions/41189726/catching-invalid-json-parse-errors-with-rack-middleware)にあるように`Rack::Head`というミドルウェアを指定すると上手く動きます。

```config/apprication.rb
module MyApp
  class Application < Rails::Application
    config.middleware.insert_before Rack::Head, 'RescueJsonParseErrors'
  end
end
```

## 対応 2. config.middleware.insert_before にミドルウェアを文字列で指定できなくなった
結論： `config/initializers/rescue_json_parse_errors.rb`でRackミドルウェアを組み込む


ミドルウェアを文字列で指定すると、次のエラーが発生します。

```sh
rails aborted!
NoMethodError: undefined method `new' for "RescueJsonParseErrors":String
```

かと言って

```config/apprication.rb
module MyApp
  class Application < Rails::Application
    config.middleware.insert_before Rack::Head, RescueJsonParseErrors
  end
end
```

とすると、今度は次のエラーが起きます。

```sh
rails aborted!
NameError: uninitialized constant MyApp::Application::RescueJsonParseErrors
```

なぜでしょうか？

```config/environment.rb
# Load the Rails application.
require_relative 'application'

# Initialize the Rails application.
Rails.application.initialize!
```

を見ればわかるように、`config/apprication.rb`が読み込まれるのは、`Rails.application.initialize!`より前です。`RescueJsonParseErrors`クラスが読み込まれる前に、参照するため定数`RescueJsonParseErrors`を見つけられません。

同じようにRackミドルウェアを読み込んでいるを`config/initializers/cors.rb`を参考にしましょう。

```config/initializers/cors.rb
# Be sure to restart your server when you modify this file.

# Avoid CORS issues when API is called from the frontend app.
# Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin AJAX requests.

# Read more: https://github.com/cyu/rack-cors

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins '*'
    resource '*',
      headers: 'Content-Type,X-Requested-With,Accept,Origin',
      methods: [:get, :post, :put, :delete, :options]
  end
end
```

`config/initializers`での初期化ではRackミドルウェアが読み込めています。真似をして、次のファイルを用意します。

```config/initializers/rescue_json_parse_errors.rb
Rails.application.config.middleware.insert_before Rack::Head, Middleware::RescueJsonParseErrors
```

ここまでで、ミドルウェアの読み込み成功します。しかし実際に動作させると期待通りには動きません。

## 対応 3. JSONパースエラーの例外がActionDispatch::ParamsParser::ParseError でなくなった
結論： `ActionDispatch::Http::Parameters::ParseError`をハンドリングする

[Ruby on Rails 5.2 リリースノート - Rails ガイド 5 Action Pack 5.1 削除されたもの](https://railsguides.jp/5_2_release_notes.html#action-pack-%E5%89%8A%E9%99%A4%E3%81%95%E3%82%8C%E3%81%9F%E3%82%82%E3%81%AE)
> 非推奨のActionController::ParamsParser::ParseErrorを削除。

`ActionDispatch::ParamsParser::ParseError`は、Rails 5.2では削除されました。

[rails/CHANGELOG.md at 5-1-stable · rails/rails Rails 5.1.0 (April 27, 2017)](https://github.com/rails/rails/blob/5-1-stable/actionpack/CHANGELOG.md#rails-510-april-27-2017)

> Deprecate ActionDispatch::ParamsParser::ParseError in favor of ActionDispatch::Http::Parameters::ParseError.

Rails 5.1の時点で、代替えとして`ActionDispatch::Http::Parameters::ParseError`が推奨されています。これに置き換えます。

```config/initializers/rescue_json_parse_errors.rb
module Middleware
  class RescueJsonParseErrors
    def initialize(app)
      @app = app
    end

    def call(env)
      @app.call(env)
    rescue ActionDispatch::Http::Parameters::ParseError
      raise unless %r{application/json}.match?(env['HTTP_ACCEPT'])

      [
        400, { 'Content-Type' => 'application/json' },
        [{ status: 400, error: 'You submitted a malformed JSON.' }.to_json]
      ]
    end
  end
end
```



# 参考
## Gemによる対応
Rails 4用のgemは2つあります。

- [catch_json_parse_errors](https://rubygems.org/gems/catch_json_parse_errors)
- [rack_middleware_json_error_msg](https://rubygems.org/gems/rack_middleware_json_error_msg) 

いまのところ、どちらもRails 5.2に対応していません。

- https://github.com/andrhamm/catch_json_parse_errors/blob/master/lib/catch_json_parse_errors/middleware.rb
- https://github.com/andys/rack_middleware_json_error_msg/blob/master/lib/rack/middleware/json-error-msg.rb


## その他の参考ページ

- [Rails にJSONでリクエストを投げた際に発生する JSON::ParserError をハンドリングする - @blog = Blog.new(author: "nyangry")](http://nyangryy.hatenablog.com/entry/2016/07/07/173455)