RailsでAPIを作るときのエラー処理について

RailsでAPIを雑に書いていたんだけど, コントローラとかをどう書くとエラー処理しやすくなっていいかなーと考えていて, 個人的に考えがまとまったのでブログ書いた.

※9/1に追記書いた.

良いエラー処理について

個人的にAPIを書く上で(API書くに限らない気はするけど)どういうふうにエラー処理を行うと良いかなーと考えてみると

  • コントローラ内では基本的に, ある関数の処理が失敗して, 次の処理が行えない場合はすべて例外を投げる
  • 例外は各々のコントローラ内で例外のキャッチは行わず, すべてApplicationControllerなど, 親コントローラ内の1メソッドで完結させる

かなーと思う. APIのエラー処理は, Envelopeにステータスコードとエラーメッセージを書いて, APIのフォーマットを統一するほうがクライアントが作りやすそうだし, またこのように処理することで, エラー処理での条件分岐の必要がなくなり, コントローラの可読性の向上にもつながる.

Grape vs Rails

APIつくるんだったら, Grapeサイコーという意見が多い.

確かにGrapeのDSLは直感的に書けるし, バリデーションなど便利メソッドが多いけど, 個人的には素のRailsでAPIを書くほうがセンスが良いと感じる. というのもRackベースなので, ルーティングなど独自のものが多く, せっかくRailsが提供してるRakeのタスクや, ジェネレータがそのまま使えないからである.

SinatraとかでAPI納品するんだったら, Grapeとかいれるのはすごい良さそう.

ただ, そのままのRailsではJSONやXMLをいい感じの構造で返す仕組みが貧弱なので, RABLを導入するのが便利. これはJSONやXMLをいい感じに生成するためのテンプレートエンジンで, DSLを用いて直感的にAPI出力を定義できる.

また, RailsのLayoutsにも対応しており, views/layouts/application.rablとかを定義しておくことで, Envelopeみたいなのを簡単に実現できる.

コントローラ内でのエラー処理

上記に上げたとおり, コントローラ内でモデルのCRUDなどの処理が失敗した場合は例外を投げてApplicationControllerに処理を渡す.

例えばshowメソッドでは以下のように処理する.

def show
  @piyo = Piyo.find_by!(:id, params[:id])
end

ApplicationControllerでのエラー処理

以下のようなConcernを定義し, ApplicationControllerから読み込むことでエラー処理を行う.

module Api::ErrorHandlers
  extend ActiveSupport::Concern

  attr_accessor :status, :message

  included do
    before_filter :setup
    rescue_from StandardError, :with => :rescue_exception
  end

  private

  def rescue_exception(e)
    @message = e.message
    if rescuable?(e)
      re = e.is_a?(Api::Exceptions::RescuableException) ? e : RESCUABLE_EXCEPTIONS[e.to_s.to_sym]
      @status = re.status
    else
      @status = 500
      @message = e.message
    end

    render 'api/errors/base'
  end

  def rescuable?(e)
    return e.is_a?(Api::Exceptions::RescuableException) || RESCUABLE_EXCEPTIONS.has_key?(e.to_s.to_sym)
  end

  def setup
    @status = 200
    @message = "OK"
  end
end

ポイントはすべての例外処理をrescue_exceptionで受け取るところである. このrescue_exceptionは投げられた例外によって, 適切なステータスコードとエラーメッセージをビューに渡すメソッドで, それらはEnvelopeとして出力される. 例えばRablのLayoutsで以下のように定義することでエラー出力する.

{
  "status": <%= @status.to_json.html_safe %>,
  "message": <%= @message.to_json.html_safe %>,
  "data": <%= yield %>
}

ここで, 例外に対応するステータスコードを以下のように引く.

  1. 独自の例外の場合は, その例外クラスにステータスを保持させる
  2. 組み込みの例外(例えばActiveRecordのNotFoundException)の場合は, 例外に対応するステータスコードの対応表から引く
  3. それ以外の例外の場合は500を返す

1の場合は, Api::Exceptions::RescuableExceptionを作成して, それを継承した独自の例外クラスを投げて対応する.

module Api::Exceptions
  class RescuableException < StandardError
    attr_accessor :status

    def initialize(status = 500, message = "Error")
      super(message)
      @status = status
    end
  end

  class UnAuthenticationException < RescuableException
    def initialize(message = "Unauthorized")
      super(401, message)
    end
  end
end

2の場合は, RESCUEABLE_EXCEPTIONSみたいなハッシュを作って対応する.

RESCUABLE_EXCEPTIONS = {
  ActiveRecord::RecordNotFound.to_s.to_sym => Api::Exceptions::RescuableException.new(404, "Record Not Found")
}

3の場合は, 上に2つの条件を満たさない場合に500を返すようにrescue_exceptionメソッドを書くことで対応する.

まとめ

ApplicationControllerでApi::ErrorHandlersを定義し, rescue_exceptionで例外処理することで, 開発速度が上がって良さそうだという個人的なエラー処理のまとめを書いてみた.

追記

@r7kamuraさんに, 以下のリプライを頂いて

確かに, Rack middlewareのこととか全く考慮できてなくてダメダメって感じだった.

そして起きたらRailsでAPIをつくるときのエラー処理っていうすごい知見がまとめられていた.