🧐

my_api_client gemのソースコードを読んでみた

2024/08/14に公開

はじめに

こんにちは、kmkntです。
ソースコードを読んでみたシリーズの第2回になります。

前回はこちらになります。
https://zenn.dev/socialplus/articles/3542120e8cd10c

今回は題材としてmy_api_clientというgemを取り上げようと思います。弊社CTOが公開しているOSSで社内でも活用しています。

https://github.com/ryz310/my_api_client

my_api_clientとは

一言でいうと、APIクライアントを作成するためのライブラリになります。

READMEの冒頭に下記のように書かれているとおり、

MyApiClient は API リクエストクラスを作成するための汎用的な機能を提供します。Sawyer や Faraday をベースにエラーハンドリングの機能を強化した構造になっています。

エラーハンドリングの機能が強化されていることが特徴で、実際に使っていてとても便利に感じています。

エラーハンドリング以外にもRSpecを書くためのスタブ機能など便利な機能があるのですが、今回はエラーハンドリングの機能に焦点を絞り、機能の紹介をして実装の詳細を見ていきたいと思います。

エラーハンドリングをどのように行うのかは、サンプルのソースコードを見てもらうのが早いかと思います。

class ExampleApiClient < MyApiClient::Base
  endpoint 'https://example.com'

  error_handling status_code: 400..499,
                 raise: MyApiClient::ClientError

  error_handling status_code: 500..599, raise: MyApiClient::ServerError do |_params, logger|
    logger.warn 'Server error occurred.'
  end

  error_handling json: { '$.errors.code': 10..19 },
                 raise: MyApiClient::ClientError,
                 with: :my_error_handling

  # Omission

  private

  # @param params [MyApiClient::Params::Params] HTTP reqest and response params
  # @param logger [MyApiClient::Request::Logger] Logger for a request processing
  def my_error_handling(params, logger)
    logger.warn "Response Body: #{params.response.body.inspect}"
  end
end

error_handlingメソッドを使って、どのようにエラーハンドリングするかを定義していることがわかります。以降では、こちらのメソッドの実装を追っていきます。

ソースコードを読む

MyApiClient::Base

APIクライアントを実装するとき、MyApiClient::Baseを継承するので、まずはそちらから見ていきます。

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/base.rb#L1-L20

エラーハンドリングに関して、ここで見るべきポイントがいくつかあります。

  • MyApiClient::ErrorHandlingの中にerror_handlingの実装があります。
  • MyApiClient::Exceptionsの中にretry_ondiscard_onの実装があります。
  • MyApiClient::DefaultErrorHandlersにデフォルトのエラーハンドリングが定義されています。

また、空配列を初期値としてclass_attributeで宣言されたerror_handlersにエラーハンドリングを持たせることになります。

MyApiClient::DefaultErrorHandlers

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/default_error_handlers.rb#L1-L64

見たとおりですが、ステータスコード400番台はClientErrorおよびそのサブクラス、500番台はServerErrorおよびそのサブクラスがraiseされることがわかります。また、NetworkErrorの場合はリトライが実行されることがわかります。

MyApiClient::ErrorHandling

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/error_handling.rb#L46-L57

ここからerror_handlingの詳細を見ていきます。

RetryOptionProcessor

RetryOptionProcessorcallメソッドにoptionsを渡して処理しているので、そちらを見てみます。

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/error_handling/retry_option_processor.rb#L30-L37

以下のようにerror_handlingにはretryオプションを定義できる仕様になっています。

error_handling json: { '$.errors.code': 20 },
               raise: MyApiClient::ApiLimitError,
               retry: { wait: 30.seconds, attempts: 3 }

error_handling json: { '$.errors.code': 20 },
               raise: MyApiClient::ApiLimitError,
               retry: true

error_handling_options.delete(:retry)でretryの値となるHashもしくはtrueを取り出していることがわかります。verify_error_handling_optionsで検証後にHash(trueの場合は空のHashに変換して)を返しています。
MyApiClient::ErrorHandling側でRetryOptionProcessorで処理されたオプションをretry_onに渡しています。最終的にはretry_onとして別で定義した場合と同じメソッドが実行されることがわかります。

MyApiClient::Baseを継承したクラスは複数のerror_handlingを定義できます。それらをerror_handlersにlambdaとして持たせています。error_handlersに直接入れず、dupで複製していますが、これはerror_handlersがclass_attributeなので、MyApiClient::Baseを継承した他のクラスのerror_handlersを汚染しないようにするためと思われます。

Generator

lambdaの中でGeneratorcallを実行しています。前振りが長くなったのですが、今回の記事の本題はここからになります。

少し脱線しますが、Generatorも先ほどのRetryOptionProcessorServiceAbstractを継承しています。クラスメソッドであるcallの中でインスタンス化してインスタンスメソッドのcallを呼んでいます。

https://github.com/ryz310/my_api_client/blob/master/lib/my_api_client/service_abstract.rb#L8-L10

ですので、まずはinitializeメソッドから見ていきます。

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/error_handling/generator.rb#L30-L33

options[:raise]の指定がなければ、MyApiClient::Errorが呼び出されることがわかります。
次のverify_and_set_argumentsメソッドでoptionsとして渡されたキーがARGUMENTSに含まれるか検証した上で、instance_variable_setで渡されたキーと値で動的に_で始まるインスタンス変数を定義しています。attr_readerでアクセサも定義されていますね。

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/error_handling/generator.rb#L7

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/error_handling/generator.rb#L80-L89

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/error_handling/generator.rb#L37

callメソッドを見ていきます。

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/error_handling/generator.rb#L39-L45

まずmatch?ですが、ここで実際のレスポンスのステータスコードとの比較をしています。

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/error_handling/generator.rb#L91-L106

続いてmatch_headers?ですが、ここでは実際のレスポンスのヘッダーとの比較ですね。比較の処理自体は先ほどのmatch?で行われていることがわかります。

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/error_handling/generator.rb#L108-L116

例えば、headers: { 'www-authenticate': /invalid token/ }のように定義された場合は、実際のwww-authenticateの値と/invalid token/が比較されることになります。

最後にmatch_body?ですが、ここでは実際のレスポンスボディのJSON文字列との比較になります。
jsonJsonPathのシンタックスで定義できるので、そちらとの比較処理を先ほどと同じくmatch?で行っています。

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/error_handling/generator.rb#L118-L129

例えば、json: { '$.errors.code': 10..19 }であれば、$.errors.codeで実際のボディの値を取り出し、10から19の間に含まれるかを見ています。

ステータスコード、ヘッダー、ボディの順に見ていって、定義と実際のレスポンスが一致しなければ何もせずnilを返しています。一致した場合にはgenerate_error_handlerに処理が移ります。

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/error_handling/generator.rb#L47-L55

まず、blockが定義されていればblock_callerが呼ばれます。

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/error_handling/generator.rb#L57-L62

error_handlingには以下のようにblockを渡せるので、blockが渡された場合には、それを実行した上でerror_raiserをcallするlambdaが定義されています。

  error_handling status_code: 500..599, raise: MyApiClient::ServerError do |_params, logger|
    logger.warn 'Server error occurred.'
  end

次に、withが定義されていればmethod_callerが呼ばれます。

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/error_handling/generator.rb#L64-L69

error_handlingには以下のようにwithでメソッドを渡せるので、メソッドが渡された場合には、それを実行した上でerror_raiserをcallするlambdaが定義されています。

  error_handling json: { '$.errors.code': 10..19 },
                 raise: MyApiClient::ClientError,
                 with: :my_error_handling

最後に、error_raiserですが、blockもwithもなければ、これだけが呼ばれることになります。_raiseをraiseするlambdaですね。

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/error_handling/generator.rb#L71-L73

再びMyApiClient::ErrorHandlingに戻るのですが、

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/error_handling.rb#L51-L55

Generatorcallメソッドにoptionsに加えてinstanceresponseも渡していました。responseは実際のレスポンスなので必要なのですが、instancemethod_callerでwithで定義されたメソッドを呼び出すために必要だったことがわかりますね。

MyApiClient::Request

ここまでerror_handlingの実装を追ってみたのですが、APIを実行した際にerror_handlersがどのように使われているかをさらっと見て終わりにしたいと思います。

MyApiClient::Requestから辿っていくと、APIを実行しているのはMyApiClient::Request::Executorcallメソッドであることがわかります。

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/request/executor.rb#L32-L43

verify(response)でレスポンスの検証をしています。

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/request/executor.rb#L66-L69

find_error_handlerを見ればいいようです。

https://github.com/ryz310/my_api_client/blob/08d2f10365c0daffdf92c3942615abe1dfa72ca2/lib/my_api_client/request/executor.rb#L80-L86

error_handlersreverse_eachで定義した順とは逆順にcallしています。callした結果がnilの場合、つまりGeneratormatch?で一致しなかった場合は何もせず、一致した場合はGeneratorgenerate_error_handlerが呼び出されることになります。

さらに、ここまで見たことでlambdaを使って定義していた理由もわかったかと思います。ロードされた時点で実行するわけではなく、実際にAPIリクエストしてレスポンスが返ってきたタイミングで実行したいからですね。

おわりに

すべてを取り上げることはできなかったですが、error_handlingメソッドに焦点を当てて読んだ内容を記事にしてみました。

お役に立てば幸いです。

SocialPLUS Tech Blog

Discussion