REST APIがエラー発生時に返すべきJSONの形（＋Railsでの実装方法）

# はじめに

- RailsのAPIでエラー処理ってどうすればいいんだろう？
- どんなJSONを返せばいいんだろう？

筆者が関わっている企業でよくこのように悩んでる人を見かけるので、自分の経験を基に記事にまとめてみました。

# 返すべきJSONの形

ぶっちゃけ、企業によってまちまちですし、「これが絶対正解だ！」と言えるものはありません。
いろんな企業のAPIがどのような形のレスポンスを返しているか知りたければ、[WebAPIでエラーをどう表現すべき？15のサービスを調査してみた](https://qiita.com/suin/items/f7ac4de914e9f3f35884)という記事が非常に参考になります。
ただ、上記の記事にも書いてありますが、[2016年に出たRFC7807](https://tools.ietf.org/html/rfc7807)が、「こういう形でいいんじゃね！？」っていう標準となるようなJSONの形を提案してくれています。

## RFC7807が提案する形

詳しくはRFCの方を参照していただければと思いますが、具体例を書くとこんな感じです。

```JSON
{
  "type": "https://your-api-url.com/problems/article-not-found",
  "title": "お探しの記事は見つかりませんでした",
  "status": 404,
  "detail": "IDが999の記事は見つかりませんでした。URLが正しいかご確認ください。",
  "instance": "/articles/999"
 }
```

|フィールド名|必須|簡単な説明（詳しくはRFCを参照）|
|---|-----|---|
|type|✔︎   |エラーの種類を識別するURI。URIに遷移すると、エラーのドキュメンテーションが返されることが望ましい。|
|title|   |エラーを説明する短い文章。typeと1:1の関係（i18nは除く）。|
|status|  |httpステータスコード|
|detail|  |titleの長いバージョン。より詳細な説明のための文章。|
|instance||エラーの原因となったリソースのインスタンスを識別するURI。|

また、上記以外にも必要に応じてフィールドを追加することも可能。

## RFC7807に準拠した最低限の形

では、実際に`type`,`title`,`status`,`detail`,`instance`は全て必要かと言われたら、そんなことはありません。
以下は、筆者の個人的な意見になりますが、各フィールドの必要性を判断したものです。

- `type`: 必須だしエラーの種類を識別するエラーコード的な役割も果たしてくれるので必要
- `title`: ユーザーに表示するエラーメッセージとして使えるので必要
- `status`: レスポンスのボディになくてもヘッダーを見ればわかるので不要
- `detail`, `instance`: あっても使う機会は少なそう（規模にもよるかもしれない）ので不要

まとめると「とりあえず`type`と`title`さえあれば困らないかな」という感じです。
なので、ミニマムな形としては以下で十分だと思います。

```JSON
{
  "type": "https://your-api-url.com/problems/article-not-found",
  "title": "お探しの記事は見つかりませんでした"
}
```

`type`は相対ぱすでも良いので、以下のような形でも大丈夫です。


```JSON
{
  "type": "/problems/article-not-found",
  "title": "お探しの記事は見つかりませんでした"
}
```

さらに、「内部でしか使わないからAPIのエラーコードのドキュメンテーションをわざわざURLでアクセスするようにしないよ！」っていう方も多いと思うので、その場合は（RFC7807の仕様からは外れてしまいますが）以下のような形でも良いと思います。

```JSON
{
  "type": "article-not-found",
  "title": "お探しの記事は見つかりませんでした"
}
```

異論は認めます。ぜひコメントを投げてください！

# Railsにおける実装方法

さて、JSONの形が決まったところで、次はRailsで実装する方法を考えます。

## 実装方法1. 返すJSONベタがき

これはスケールしないのであまりおすすめしませんが、すごく小規模なアプリケーション、使い捨てのアプリケーション、なんらかの制約でとりあえず早くリリースしなければいけない場合などはありかなと思います。

```ruby
class HogesController
  before_action :authenticate!
 
  def show
    # ログインしてないと見れない情報を返す
  end

  private

  # こういう共通で使いそうなメソッドは実際には親コントローラーやconcernに定義されてそうだが、
  # 今回は簡単にするためにprivate methodとして定義する
  def authenticate!
    @user = # 認証するコード
    return if @user
    render json: { type: '/problems/authentication_required', title: 'ログインしてください' },
           status: 401,
           content_type: 'application/problem+json'
  end
end
```

全てのコントローラーに継承される親コントローラーに、このようにJSONをとりあえずベタがきで書いて返します。
親コントローラーがない場合は、各コントローラーに`include`されるモジュールでも良いと思います。

## 実装方法2. エラーごとにクラスを定義し、SerializerでJSONを組み立てる

### 1. エラーの親クラスを作成

まず、`ApiExceptions::BaseException`と言う、エラークラスの親を作成します。

```ruby
# app/models/api_exceptions/base_exception.rb
module ApiExceptions
  class BaseException < StandardError
    attr_reader :status_code, :type, :title

    def initialize
      raise NotImplementedError
    end
  end
end
```

### 2. 個別のエラークラスを作成

次に、個別のエラークラスを作成します。

```ruby
# 例） 認証エラー
# app/models/api_exceptions/authentication_required.rb
module ApiExceptions
  class AuthenticationRequired < BaseException
    def initialize
      @status_code = 401 # Unauthorized
      @type        = '/problems/authentication_required'
      @title       = 'ログインしたください'
    end
  end
end
```

### 3. 例外のSerializerを作成

エラークラスをJSONに変換する`ApiExceptionSerializer`を作成します。
今回の例では、[ActiveModel::Serializer](https://github.com/rails-api/active_model_serializers)を使っていますが、他のSerializerでも同じことを実現できるはずです。

```ruby
# app/serializers/api_exception_serializer.rb
class ApiExceptionSerializer < ActiveModel::Serializer
  attributes :type, :title
end
```

### 4. 親コントローラーで`BaseException`を`rescue`する

```ruby
# app/controllers/base_controller.rb
class BaseController < ActionController::API
  rescue_from ApiExceptions::BaseException, with: :render_api_exception

  private

  def render_api_exception(exception)
    render json:         exception,
           serializer:   ApiExceptionSerializer,
           status:       exception.status_code,
           content_type: 'application/problem+json'
  end
end
```

### 5. 各コントローラーで`raise`する

```ruby
class HogesController < BaseController
  before_action :authenticate!
 
  def show
    # ログインしてないと見れない情報を返す
  end

  private

  def authenticate!
    @user = # 認証するコード
    raise ApiExceptions::AuthenticationRequired unless @user
    # 認証なしでアクセスすると以下のJSONが返される
    # {
    #   "type": "/problems/authentication_required",
    #   "title": "ログインしてください"
    # }
  end
end
```

### このアプローチの何が嬉しいか

実装方法1.と比べると、

- 各コントローラーで書くのはエラーを`raise`するコードのみなので、コントローラーをスリムに保てる
- エラーの種類に対応するクラスを`app/models/api_exceptions`配下に必ず作成しなければいけないので、どんなエラーの種類があるのかわかりやすい＝ドキュメンテーションになる。
- JSONを生成する責務をコントローラーからSerializerに移せるので責務がいい感じに別れる
- `content_type: 'application/problem+json'`のような共通で返す必要があるJSONは一箇所に定義すれば済む

# まとめ
- エラーで返すJSONは[RFC7807](https://tools.ietf.org/html/rfc7807)に準拠するのが無難
  - 最低限、エラーの種類を表す`type`と、エラー内容の説明をする`title`は入れよう
  - その他は必要に応じて追加すれば良い
- エラーの種類ごとにクラスを定義するといい感じに書ける