はじめましてこんにちは。 夏が本気を出してきて最近麺類しか口にしていないサーバサイドエンジニアのかしまです。 この度 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::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?( /\A translation 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?( /\A translation 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?( /\A translation 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名をセットしてるのかまで追いたかったのですが、力及ばすでした。無念。