弊社のプロダクトではRailsをモバイルクライアントアプリ向けのAPIサーバーのバックエンドとして利用しています。 HTMLにエラーの詳細を表示する事ができるWebページとは違い、 モバイルクライアントではエラーレスポンスだけでエラーの有無やエラーの詳細を判定し、 さらにはエラーの詳細な内容によって画面遷移等の処理の分岐をする必要があります。 そのため、エラーのレスポンスにはエラーの詳細な種類を判別する事ができるコードのような物を含めておきたいです。 そこで、今回は、HTTP通信のエラーレスポンスフォーマットとして提唱されているRFC 7807のProblem Details形式を参考に以下のような形式としました:
{ "error": { "type": "ApiError::Foo::Bar", "status": 422, "title": "Unprocessable Entity", "details": ["Bazは100以下である必要があります"] } }
Problem Details形式
HTTPの通信のエラーフォーマットを各々の会社やエンジニアが考案している現状をふまえての共通のエラーフォーマットとして提唱されており、 最終的に独自に設計するにしても、RFC7807で提案されている設計やその根拠や背景は一度把握しておく事が良いと感じました。
Problem Details形式との違い
今回採用する事にしたフォーマットは、以下の点でProblem Details形式と異なります:
- typeがURI形式ではない
- detailという文字列フィールドではなく、detailsという配列フィールドを利用している
URI形式ではないtypeフィールド
Problem Details形式では、type
フィールドは URI 形式である事が求められています。
さらには、 type
フィールドにふくまれる URI 形式を参照解決した際にはエラーへの対処方法が書かれたHTMLのドキュメントになる事を推奨するとかかれています。
この点については、
- URI 形式とする事で永続的でグローバルで一意なエラーである事を表現している
- 開発者がエラーへの対処方法を確認するための文書への自然なリンクとなっている
という点で魅力的です。
一方で、弊社のAPIは
- 内部APIとして利用しているため、サービス内で一意であれば良い
- エラーの文書を作成したとしても、社内メンバーに限定して公開したい
という環境でした。 Problem Detailsではエラーの文書へのリンクのスキーマはhttpやhttpsを推奨している事もあり、 実際のプログラムの実行中には(ほぼ)参照される可能性の低い文書のために、クライアントコード内の識別子管理が複雑になったり、文書のセキュリティ管理を実装するコストはかけられないと判断しました。
配列形式のdetails
Railsではモデルのバリデーションエラー等は一つとは限らず複数のエラーが発生する事があります。
そういったエラーを表現するにあたって、errors.full_message
を改行や"\n" カンマ ","でjoinして一つの文字列にしてしまう
というのも考えましたが、クライアント側で仮に表示する場面で柔軟性にかける可能性があると判断し、配列形式のままで追加する事にしました。
エラーtypeの管理
エラーtypeを実際に用意するにあたって、サービス内で一意な識別子として利用できるという要件を守る必要があります。 この要件が崩れてしまうと、意図しない衝突によってクライアントが正しくエラーを識別できなくなるかもしれません。 そこで、実装するにあたっては以下の二つの大きなパターンがあります:
- 特定のモジュールに、文字列の定数として列挙する
- エラーのクラスとして実装し、クラス名をエラーのtypeとして利用する
これらの二つのアプローチを比較した時に、クラスとして実装する形式は文字列としての列挙よりもメリットがあります:
- 定数名と実際のtypeの両方を管理する必要が無い
- クラスの一意性によって自動的に一意性が担保される
定数名と実際のタイプの文字列をmodule以下に並べる形式にした場合、結局は文字列の一意性は人間が管理しなければなりません。 そのため、クラスによってエラーを管理するという方式を取る事にしました。
エラークラスの構成
では、そのクラスをどこに配置するのかという設計が必要になります。 クラスの配置には大きく二つの観点がここでは関連してきます:
- オブジェクト指向としてのクラス階層
- クライアントから安定して使える識別子である事
今回あつかうエラーを、オブジェクト指向の観点からながめると、 例えば特定のForm内でのバリデーションによって発生するエラーはFormオブジェクトの配下に置くような、オブジェクト指向としての設計観点があります。 その場合、モデルによって発生するエラーや、Formオブジェクト、コントローラレベルで発生するエラーといった様々な階層に今回のクラスが配置される事になります。
しかし、そのように設計した場合、今回想定している「クラス名をtypeとしてつかう」という観点で見たときに実際のtypeは以下のようになります:
- User::SignUpForm::DuplicateUserError
- User::EmailCanNotBeEmptyError
- UserController::RecordNotFound
しかし、このように設計した場合、オブジェクト指向としては自然になりますが、 第一にクライアントに実装の詳細が過剰に漏れているように思われます、APIを利用する観点から見ると「ユーザーが重複していた事」や「Emailが空ではいけない事」、「対象のユーザーが見つからなかった事」という抽象度でエラーが判別できる事が主目的であり、実装としてどのようなクラス階層になっているかには関心はありません。
また、このような設計にした場合SignUpFormといったクラス階層は今後の要件によって変化する可能性があり、その度にAPIのインタフェースが変化する事になります。 一方で、重複したユーザーが作れないといったビジネスロジックはクラス階層よりも変更の可能性が低いです。 長く安定してクライアントが開発していけるという視点から見ると、 疎結合で柔軟に要件にあわせて変更しやすく設計するというオブジェクト指向設計のメリットが逆効果となってしまいます。
そこで、今回は、「一意な識別子である」という事を優先しApiErrorというモジュール配下にまとめる事としました。
. ├── car_info │ ├── already_registered.rb │ └── failed_to_create.rb ├── driver │ ├── failed_to_sign_up.rb │ └── failed_to_update_profile.rb ├── record_not_found.rb └── unauthorized.rb
実装
さて、エラーはApiErrorモジュール以下に個別のクラスとして配置するという設計できまりましたので、 次は、実際にエラーレスポンスをコントローラで返す実装をしました。
例外を投げるか、renderするか
所定のエラーフォーマットを返すにあたって、以下の二つの実装方法を比較しました:
- 例外クラスとして実装し、raiseする事によってcontrollerの
rescue_from
でレンダリングする - カスタムrenderを登録し、通常のレスポンスと同様にレンダリングする
そして、今回はカスタムrenderを利用する事としました。
判断の観点
例外として投げ、rescue_from
を利用する実装についても考えましたが、今回のレスポンス形式はProblem Details形式を参考にしたという事もあり
レスポンスに status
フィールドが含まれています。
そのため、rescue_from
で実装する場合は、一度エラーに想定しているステータスを含め、rescue_from
内で取りだしてレンダリングするという形式になります。
そして、railsのrenderではstatusが:ok
のようなシンボルや実際の200
といった数値の両方で指定する事ができます。
今回私の判断としては
- APIのエラーという概念とステータスコード自体を切り離したい
- レスポンスに含めるために一度フィールドに設定された値を取りだして数字に変換して書きこみなおすという処理への違和感
という2点からrenderを利用する事にしましたが、 renderをする際には以下のようにレンダラをinitializerで登録する必要があります
# config/initializers/api_error_renderer.rb # frozen_string_literal: true ActiveSupport.on_load(:action_controller) do ActionController::Renderers.add :api_error do |content, options| render json: ApiError::ResponseHashBuilder.build(api_error: content, status: options[:status]), **options end end
そのため、一定このコード自体がon_load等のトリッキーな部分を含んでいるためここの保守コストが長期的にかかるかもしれないというリスクが有ります。
レスポンスの生成
このレンダリング処理ではApiError::ResponseHashBuilder
というクラスに処理を委譲しています。
このクラスは以下のように実装されており:
# frozen_string_literal: true module ApiError class ResponseHashBuilder class << self # @param [ApiError] api_error # @param [Symbol, Integer] status # @return [Hash] def build(api_error:, status: :ok) new(api_error, status).run end end private_class_method :new def initialize(api_error, status) @api_error = api_error @status = status || :ok end # @return [Hash] def run { error: { type: error_type, title: error_title || status_code_title, details: error_details, status: status_code, }, } end private attr_reader :api_error, :status # @return [String] def error_type api_error.class.name end # @return [String] def error_title api_error.title end # @return [Array<String>] def error_details api_error.details end # @return [Integer, nil] def status_code ::Rack::Utils.status_code(status) end # @return [String] def status_code_title ::Rack::Utils::HTTP_STATUS_CODES[status_code].to_s end end end
::Rack::Utils::
を利用してステータスコードのシンボルを数字に変換したり、ステータスコードからHTTP Status名の文字列に変換したりしています。
このクラスの実装は
でProblem Details形式のレスポンスを返すためのライブラリを実装してくださっているコードを参考にさせていただきました。
コントローラでのレンダラの呼びだし
登録したレンダラ api_error
は以下のようにコントローラから呼びだしています。
if CarInfo.exists?(driver: current_driver) return render api_error: ApiError::CarInfo::AlreadyRegistered.new, status: :conflict end
このようにする事で
{ "error": { "type": "ApiError::CarInfo::AlreadyRegistered", "title": "Conflict", "status": 409, "details": [] } }
というレスポンスがクライアントに返る事となります。
課題
API毎の取りえる識別子のドキュメンテーション
このように統一的に決めたフォーマットですが、私たちのチームではOpenAPIを利用してクライアントとのIF共有に利用しています そこで、ApiErrorのスキーマは以下のようにOpenAPI内で定義しています
ApiError: type: object required: - error properties: error: type: object required: - type - title - status - details properties: type: type: string description: エラーの一意な識別子 enum: - ApiError::RecordNotFound - ApiError::Unauthorized - ApiError::CarInfo::AlreadyRegistered - ApiError::CarInfo::FailedToCreate - ApiError::Driver::FailedToSignUp - ApiError::Driver::FailedToUpdateProfile status: type: integer description: レスポンスのHTTPステータス details: type: array items: type: string description: バリデーションエラー等 title: type: string description: エラー概要
このようにtypeをenumで管理する事によってレスポンススキーマをcommittee等で検証し、 エラーがきちんとドキュメントに記載されているかを確認する事もできます。
一方で、「このエンドポイントで、どのエラータイプがかえってくる可能性があるのか」という事はドキュメンテーションできておらず、 OpenAPIでこのApiErrorを雛形に、enumをレスポンス毎に制限する事ができないため、こちらについては別途OpenAPI外で共有する必要があると考えています。