Let's write β

プログラミング中にできたことか、思ったこととか

モバイルクライアント向けRailsAPIサーバーのエラーレスポンス設計

弊社のプロダクトではRailsをモバイルクライアントアプリ向けのAPIサーバーのバックエンドとして利用しています。 HTMLにエラーの詳細を表示する事ができるWebページとは違い、 モバイルクライアントではエラーレスポンスだけでエラーの有無やエラーの詳細を判定し、 さらにはエラーの詳細な内容によって画面遷移等の処理の分岐をする必要があります。 そのため、エラーのレスポンスにはエラーの詳細な種類を判別する事ができるコードのような物を含めておきたいです。 そこで、今回は、HTTP通信のエラーレスポンスフォーマットとして提唱されているRFC 7807のProblem Details形式を参考に以下のような形式としました:

{
  "error": {
    "type": "ApiError::Foo::Bar",
    "status": 422,
    "title": "Unprocessable Entity",
    "details": ["Bazは100以下である必要があります"]
  }
}

Problem Details形式

tools.ietf.org

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名の文字列に変換したりしています。 このクラスの実装は

qiita.com

で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外で共有する必要があると考えています。