RailsのAPIサーバーのエラーレスポンスで例外に対応するエラーコードを返却する

はじめましてこんにちは。
夏が本気を出してきて最近麺類しか口にしていないサーバサイドエンジニアのかしまです。

この度APIにてHTTP Status Codeとは別に、例外に対応するエラーコードを返すよう奮闘したのでその知見を共有したいと思います。

やりたいこと

APIにて例外が発生した場合、以下の形式でレスポンスを返すようにします。

{
  "errors":
  [
     {
        "code":  "code1",
        "message": "message1",
     },
     {
        "code":  "code2",
        "message": "message2",
     },
  ]
}

Why?

今回追加したAPIは外部のアプリケーションとの連携に使うため、以下の要件を満たす必要がありました。

  • クライアントサイドがハンドリングしやすいような設計にする
  • エラーの識別子は不変であること

そのためエラーメッセージとは別に、コードを返すことにしました。

次の章から実際にどのように実装したかを紹介したいと思います。

rescue_fromにて例外をキャッチする

rescue_fromとは例外をキャッチして指定したメソッドを実行してくれるという大変便利なものです。

railsguides.jp

これをAPIの基盤となるcontrollerに埋め込みます。

  class APIController < ActionController::Base
    rescue_from StandardError, with: :error500

    def error500(error)
      render json: { errors: [{ code: 'E9999', message: '例外が発生しました' }] }, status: :internal_server_error
    end
  end

このクラスを継承したcontrollerにてStandardErrorが発生するとerror500メソッドが呼び出され、期待するエラーレスポンスを返すことができます。簡単ですね。
他の例外も同じように実装していけば良さそうです。

Modelのvalidationエラーの場合

さてここからが本題なのですが ActiveRecord::RecordInvalid (modelのvalidationエラー)が発生した場合、例外の原因に対応したエラーコードを動的に取得して返すようにします。
独自で定義したModelのvalidationはサービスのドメインに紐づくものも多いため、同じ例外でも原因をコードで識別できるようにするためです。

  class Offering < ApplicationRecord
    # Rails標準の汎用的なvalidation
    validates :start_at, presence: true
    validates :end_at, presence: true
    # 独自のvalidation
    # これらが発生した場合はそれぞれ定義したエラーコードを返したい
    validate :check_minimum_hourly_wage
    validate :check_rests_presence_and_minutes
    
    private

    def check_minimum_hourly_wage
      # 処理

      errors.add(:hourly_wage, :greater_than_prefecture_minimum_wage)
    end

    def check_rests_presence_and_minutes
      # 処理

      errors.add(:base, :greater_then_or_eq_default_rest_minutes)
    end
  end

どうやってエラーコードを取得するか

ActiveRecord::RecordInvalid をrescue_fromにてキャッチすると、以下のように ActiveModel::Errorsインスタンスにアクセスできます。

    def error404(error)
      error.record.errors.class
      # => ActiveModel::Errors
    end

ActiveModel::Errorsインスタンスdetails というattributesを持っています。
details 内にエラーが発生したattribute名をkey、エラーメッセージkeyをvalue*1 として配列で持っています。

error.record.errors.details
=> {:base=>[{:error=>:greater_then_or_eq_default_rest_minutes}]} # 配列内ではerrorをkey、エラーメッセージkeyをvalueとして持っている

これらの情報からエラーコードを取得すれば良さそうです。

エラーコードの管理について

タイミーでは定数管理にConfigを使っているのでエラーコードもymlで管理することにしました。

error_codes:
  bad_request: E0400
  unauthorized: E0401
  forbidden: E0403
  not_found: E0404
  unprocessable_entity: E0422
  internal_server_error: E9999
  models:
    offering:
      base:
        greater_then_or_eq_default_rest_minutes: E2000
      hourly_wage:
        greater_than_prefecture_minimum_wage: E3000

これにより、以下のようにエラーコードを取得できます。

Settings.error_codes.models.user.base.hoge
=> E2000

エラーメッセージとエラーコードを対応させる

エラーコードの取得方法が決まったのであとはエラーメッセージとペアにすれば良さそうです。
ActiveModel::Errorsインスタンスmessagesというattributes名でエラーが発生したattribute名をkey、エラーメッセージkeyからlocaleに対応したエラーメッセージをvalueとして配列で持っています。

error.record.errors.messages
=> {:base=>["法的休憩時間を満たしていません"]}

しかしこれではどのメッセージがどのエラーメッセージkeyに対応するのかわかりません。
そのため messages の情報を使わず、 details の情報からエラーメッセージも取得する必要があります。

エラーメッセージを取得するため、どのようにエラーメッセージが格納されているのかを把握するため、ActiveModel::Errors#add メソッドをソースを読んでみると、

https://github.com/rails/rails/blob/3f470a7e3a22304f095535b8cc730b4926828175/activemodel/lib/active_model/errors.rb#L311 https://github.com/rails/rails/blob/3f470a7e3a22304f095535b8cc730b4926828175/activemodel/lib/active_model/errors.rb#L535

    def add(attribute, message = :invalid, options = {})
      message = message.call if message.respond_to?(:call)
      detail  = normalize_detail(message, options)
      message = normalize_message(attribute, message, options)
      if exception = options[:strict]
        exception = ActiveModel::StrictValidationFailed if exception == true
        raise exception, full_message(attribute, message)
      end

      details[attribute.to_sym]  << detail
      messages[attribute.to_sym] << message
    end

~ 略 ~

  private
    def normalize_message(attribute, message, options)
      case message
      when Symbol
        generate_message(attribute, message, options.except(*CALLBACKS_OPTIONS))
      else
        message
      end
    end

どうやら generate_message をいうメソッドを使えば messages に格納されているメッセージを生成できそうです。

以上を踏まえた上で完成したメソッドがこちら

    def error422(err)
      class_name = err.record.class.name.underscore
      active_model_errors = err.record.errors
      errors = []
      active_model_errors.details.each do |attribute, attribute_errors|
        attribute_errors.each do |error_info|
          # エラーコードとエラーメッセージをセットにするため、full_messagesではなく
          # generate_messageとfull_messageを使ってエラーメッセージを生成している
          error_key = error_info.delete(:error)
          message = active_model_errors.generate_message(
            attribute, error_key, error_info.except(*ActiveModel::Errors::CALLBACKS_OPTIONS),
          )

          errors <<
            {
              code: Settings.error_codes.models.send(class_name)&.send(attribute)&.send(error_key) ||
                    Settings.error_codes.unprocessable_entity, # error_codesにて定義のないエラーの場合、汎用的なエラーコードを返す
              message: active_model_errors.full_message(attribute, message),
            }
        end
     end

     render json: { errors: errors }, status: :unprocessable_entity
  end

試しにvalidationエラーを起こしてみた結果以下のレスポンスが返りました🎉

{
  "errors":
  [
     {
        "code":  "E2000",
        "message": "法的休憩時間を満たしていません",
     }
  ]
}

問題点

一見良さそうですが、以下の問題が発覚しました。

1. errors.add時にoptionでerrorという名のkeyを渡されると、エラーメッセージkeyが上書きされる

errors.addメソッドでは、以下のようにoptionを渡せます。

errors.add(:base, :hoge, reason: 'reason')

渡したoptionはlocaleファイル内で参照できたりします。

ja:
  activerecord:
    errors:
      models:
        user:
          attributes:
            base:
              hoge: %{reason} のため登録できません

そして ActiveModel::Errorsdetails の内容は以下のようになります。

error.record.errors.details
=> {:base=>[{:error=>:hoge, :reason=>'reason'}]}

では errors.add時にerrorというオプションを渡したらどうなるかというと。。。

errors.add(:base, :hoge, error: 'error')
errors.details
=> {:base=>[{:error=>'error'}]}

はい。エラーメッセージkeyが上書きされてますね。

以下の処理でoptionのmergeを行った結果をdetailsに格納しているのでこのような結果になります。 https://github.com/rails/rails/blob/3f470a7e3a22304f095535b8cc730b4926828175/activemodel/lib/active_model/errors.rb#L545

    def normalize_detail(message, options)
      { error: message }.merge(options.except(*CALLBACKS_OPTIONS + MESSAGE_OPTIONS))
    end

対応策

幸い現時点でerrorオプションを使用しているコードはありませんでしたが、今後もしerrorオプションを渡された場合、エラーメッセージkeyの取得は不可能です。
そのため、エラーメッセージkeyが上書きされgenerate_messageにて適切なメッセージが取得できなかった場合は汎用的なメッセージを返すと共に適切なメッセージを返せなかったことを検知*2できるようにしました。

    def error422(err)
      class_name = err.record.class.name.underscore
      active_model_errors = err.record.errors
      errors = []
      active_model_errors.details.each do |attribute, attribute_errors|
        attribute_errors.each do |error_info|
          # エラーコードとエラーメッセージをセットにするため、full_messagesではなく
          # generate_messageとfull_messageを使ってエラーメッセージを生成している
          error_key = error_info.delete(:error)
          message = active_model_errors.generate_message(
            attribute, error_key, error_info.except(*ActiveModel::Errors::CALLBACKS_OPTIONS),
          )

          if message.match?(/\Atranslation missing:/)
            message = I18n.t('errors.messages.exceptions.unprocessable_entity')
            # Sentry通知
            LogService.error_with_sentry(error, '外部向けAPIにて適切なエラーメッセージを返せませんでした。')
          end

          errors <<
            {
              code: Settings.error_codes.models.send(class_name)&.send(attribute)&.send(error_key) ||
                    Settings.error_codes.unprocessable_entity, # error_codesにて定義のないエラーの場合、汎用的なエラーコードを返す
              message: active_model_errors.full_message(attribute, message),
            }
        end
     end

     render json: { errors: errors }, status: :unprocessable_entity
  end

追加した処理は以下の部分です。

  if message.match?(/\Atranslation missing:/)
    message = I18n.t('errors.messages.exceptions.unprocessable_entity')
    # Sentry通知
    LogService.error_with_sentry(error, '外部向けAPIにて適切なエラーメッセージを返せませんでした。')
  end

generate_message内部ではI18nを使用しています。
オプションで渡す場合、locale keyが存在する確率は低いだろうという思想のもと、戻り値に translation missing: が含まれる場合、汎用的なメッセージを返すと共にSentryに通知するようにしました。 *3

2. 関連テーブルのvalidationに引っかかった場合、generate_messageで例外を吐く

ActiveRecordでは関連テーブルを含めたデータ保存を以下のように行うことができます。

offering = Offering.new(offering_params)
offering.rests.new(rests_params)
offering.save!

上記の例で、restsの保存処理にてvalidationエラーが発生すると、generate_messageメソッドの内部でエラーが発生しました。

NoMethodError: undefined method `rests.start_at' for #<Offering:0x00005570d93a1de0>
Did you mean?  rests_attributes=
from /usr/local/bundle/gems/activemodel-6.0.3.2/lib/active_model/attribute_methods.rb:432:in `method_missing'
Caused by ActiveRecord::RecordInvalid: 休憩開始時間は休憩終了時間より大きい値にしてください
from /usr/local/bundle/gems/activerecord-6.0.3.2/lib/active_record/validations.rb:80:in `raise_validation_error'

なんだこれは。。ということでgenerate_messageメソッドのソースを読んでみると

https://github.com/rails/rails/blob/3f470a7e3a22304f095535b8cc730b4926828175/activemodel/lib/active_model/errors.rb#L482

    def generate_message(attribute, type = :invalid, options = {})
      type = options.delete(:message) if options[:message].is_a?(Symbol)
      value = (attribute != :base ? @base.send(:read_attribute_for_validation, attribute) : nil)

attribute名がbaseではない場合、レコードに対してattributeのメソッド呼び出しを行なっています。*4

つまりdetails に格納されているattribute名が rests.start_at になっているため、そんなメソッドはねぇ!と怒られているようです。

対応策

どうやら関連テーブルのデータにてvalidationエラーが起きた場合、attribute名は#{relation名}.#{attribute名} になるということがわかりました。 *5 そのためattribute名に.が存在したらrelation名とattribute名に分割し、関連レコードをチェックして、エラーが発生していたらコードを取得してメッセージを生成を行うようにしました。

    def error422(err)
      errors = ::ExternalCoordinationAPI::ErrorResponse.new(err).create.errors
      render json: { errors: errors }, status: :unprocessable_entity
    end
module ExternalCoordinationAPI
  class ErrorResponse
    attr_reader :record, :error, :errors, :active_model_errors

    def initialize(error)
      @error = error
      @record = error.record
      @errors = []
      @active_model_errors = record.errors
    end

    def create
      active_model_errors.details.each do |attribute, options|
        if attribute.to_s.include?('.')
          relation_name, _attribute_name = attribute.to_s.split('.')
          record.send(relation_name).each do |relation_record|
            next if relation_record.errors.empty?

            relation_record_active_model_errors = relation_record.errors
            relation_record_active_model_errors.details.each do |relation_record_attribute, relation_options|
              relation_options.each do |option|
                error_key = option.delete(:error)
                message = generate_message(relation_record_attribute, error_key, option)
                add_error(relation_record.class.name.underscore, relation_record_attribute, error_key, message)
              end
            end
          end
        else
          options.each do |option|
            error_key = option.delete(:error)
            message = generate_message(attribute, error_key, option)
            add_error(record.class.name.underscore, attribute, error_key, message)
          end
        end
      end
      self
    rescue StandardError => e
      LogService.error_with_sentry(e, '外部向けAPIにて422エラー生成時にエラーが発生しました。')
      errors <<
        {
          code: Settings.error_codes.unprocessable_entity,
          message: I18n.t('errors.messages.exceptions.unprocessable_entity'),
        }
      self
    end

    private

    def generate_message(attribute, error_key, options)
      # エラーコードとエラーメッセージをセットにするため、full_messagesではなく
      # generate_messageとfull_messageを使ってエラーメッセージを生成している
      message = active_model_errors.generate_message(
        attribute, error_key, options.except(*ActiveModel::Errors::CALLBACKS_OPTIONS),
      )

      if message.match?(/\Atranslation missing:/)
        message = I18n.t('errors.messages.exceptions.unprocessable_entity')
        LogService.error_with_sentry(error, '外部向けAPIにて適切なエラーメッセージを返せませんでした。')
      end

      message
    end

    def add_error(class_name, attribute, error_key, message)
      errors <<
        {
          code: Settings.error_codes.models.send(class_name)&.send(attribute)&.send(error_key) ||
                Settings.error_codes.unprocessable_entity,
          message: active_model_errors.full_message(attribute, message),
        }
    end
  end
end

もしgenerate_messageにてまだ予想できていないエラーが発生した場合に備え、StandardErrorをキャッチして汎用的なメッセージを返すようにしました。

終わりに

正直かなり力技に頼った実装になってしまっており、もっとうまくできるのではないかと思っています。
特にerrorオプションを渡せないなど本来の機能を制限してしまっている箇所はどうにかしたい気持ちが強いです。
もし良い方法をご存知の方がいらっしゃったら是非アドバイスをいただけたらと思います。

*1:errors.add時に第二引数に渡した値が入ります。タイミーではエラーメッセージは全てlocaleで定義しているので、errors.add(:base, 'エラーメッセージ')というように文字列を渡さないようにしています。

*2:タイミーではSentryを使ってます

*3:対応するlocale keyが存在したら。。。オプションkeyにerrorは使用しないというルール化をするほうが良さそうです。

*4:read_attribute_for_validationはsendのaliasでした。
https://github.com/rails/rails/blob/e6c6f1b4115495b27a2d32a4bd5c95256db695b1/activemodel/lib/active_model/validations.rb#L402

*5:どの箇所でこのようなattribute名をセットしてるのかまで追いたかったのですが、力及ばすでした。無念。