はじめましてこんにちは。
夏が本気を出してきて最近麺類しか口にしていないサーバサイドエンジニアのかしまです。
この度APIにてHTTP Status Codeとは別に、例外に対応するエラーコードを返すよう奮闘したのでその知見を共有したいと思います。
やりたいこと
APIにて例外が発生した場合、以下の形式でレスポンスを返すようにします。
{ "errors": [ { "code": "code1", "message": "message1", }, { "code": "code2", "message": "message2", }, ] }
Why?
今回追加したAPIは外部のアプリケーションとの連携に使うため、以下の要件を満たす必要がありました。
- クライアントサイドがハンドリングしやすいような設計にする
- エラーの識別子は不変であること
そのためエラーメッセージとは別に、コードを返すことにしました。
次の章から実際にどのように実装したかを紹介したいと思います。
rescue_fromにて例外をキャッチする
rescue_fromとは例外をキャッチして指定したメソッドを実行してくれるという大変便利なものです。
これを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::Errors
の details
の内容は以下のようになります。
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メソッドのソースを読んでみると
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名をセットしてるのかまで追いたかったのですが、力及ばすでした。無念。