my_api_client

A framework of Web API Client. Provides features error handling, retrying, pagination and so on.

20
3
Ruby

CircleCI Gem Version Maintainability Test Coverage Dependabot Status

MyApiClient

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

ただし、 Sawyer はダミーデータの作成が難しかったり、他の gem で競合することがよくあるので、将来的には依存しないように変更していくかもしれません。

また、 Ruby on Rails で利用することを想定してますが、それ以外の環境でも動作するように作っています。不具合などあれば Issue ページからご報告下さい。

[toc]

Supported Versions

  • Ruby 3.1, 3.2, 3.3
  • Rails 6.1, 7.0, 7.1, 7.2

Installation

my_api_client を Gemfile に追加して下さい:

gem 'my_api_client'

Ruby on Rails を利用している場合は generator 機能を利用できます。

$ rails g api_client path/to/resource get:path/to/resource --endpoint https://example.com

create  app/api_clients/application_api_client.rb
create  app/api_clients/path/to/resource_api_client.rb
invoke  rspec
create    spec/api_clients/path/to/resource_api_client_spec.rb

Usage

Basic

最もシンプルな利用例を以下に示します。

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

  attr_reader :access_token

  def initialize(access_token:)
    @access_token = access_token
  end

  # GET https://example.com/v1/users
  #
  # @return [Sawyer::Resource] HTTP resource parameter
  def get_users
    get 'users', headers: headers, query: { key: 'value' }
  end

  # POST https://example.com/v1/users
  #
  # @param name [String] Username which want to create
  # @return [Sawyer::Resource] HTTP resource parameter
  def post_user(name:)
    post 'users', headers: headers, body: { name: name }
  end

  private

  def headers
    {
      'Content-Type': 'application/json;charset=UTF-8',
      'Authorization': "Bearer #{access_token}",
    }
  end
end

api_clinet = ExampleApiClient.new(access_token: 'access_token')
api_clinet.get_users #=> #<Sawyer::Resource>

クラス定義の最初に記述される endpoint にはリクエスト URL の共通部分を定義します。後述の各メソッドで後続の path を定義しますが、上記の例だと get 'users' と定義すると、 GET https://example.com/v1/users というリクエストが実行されます。

次に、 #initialize を定義します。上記の例のように Access Token や API Key などを設定することを想定します。必要なければ定義の省略も可能です。

続いて、 #get_users#post_user を定義します。メソッド名には API のタイトルを付けると良いと思います。メソッド内部で #get#post を呼び出していますが、これがリクエスト時の HTTP Method になります。他にも #patch #put #delete が利用可能です。

Pagination

API の中にはレスポンスに結果の続きを取得するための URL を含んでいるものがあります。

MyApiClient では、このような API を enumerable に扱うための #pageable_get というメソッドを用意しています。以下に例を示します。

class MyPaginationApiClient < ApplicationApiClient
  endpoint 'https://example.com/v1'

  # GET pagination?page=1
  def pagination
    pageable_get 'pagination', paging: '$.links.next', headers: headers, query: { page: 1 }
  end

  private

  def headers
    { 'Content-Type': 'application/json;charset=UTF-8' }
  end
end

上記の例の場合、最初に GET https://example.com/v1/pagination?page=1 に対してリクエストが実行され、続けてレスポンス JSON の $.link.next に含まれる URL に対して enumerable にリクエストを実行します。

例えば以下のようなレスポンスであれば、$.link.next"https://example.com/pagination?page=3" を示します。

{
  "links": {
    "next": "https://example.com/pagination?page=3",
    "previous": "https://example.com/pagination?page=1"
  },
  "page": 2
}

そして #pageable_getEnumerator::Lazy を返すので、 #each#next を実行することで次の結果を取得できます。

api_clinet = MyPaginationApiClient.new
api_clinet.pagination.each do |response|
  # Do something.
end

result = api_clinet.pagination
result.next # => 1st page result
result.next # => 2nd page result
result.next # => 3rd page result

なお、#each はレスポンスに含まれる paging の値が nil になるまで繰り返されるのでご注意ください。例えば #take と組み合わせることでページネーションの上限を設定できます。

#pageable_get の alias として #pget も利用可能です。

# GET pagination?page=1
def pagination
  pget 'pagination', paging: '$.links.next', headers: headers, query: { page: 1 }
end

Error handling

my_api_client ではレスポンスの内容によって例外を発生させるエラーハンドリングを定義できます。ここでは例として前述のコードにエラーハンドリングを定義しています。

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

一つずつ解説していきます。まず、以下のように status_code を指定するものについて。

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

これは ExampleApiClient からのリクエスト全てにおいて、レスポンスのステータスコードが 400..499 であった場合に MyApiClient::ClientError が例外として発生するようになります。 ExampleApiClient を継承したクラスにもエラーハンドリングは適用されます。ステータスコードのエラーハンドリングは親クラスで定義すると良いと思います。

なお、 status_code には Integer Range Regexp が指定可能です。

raise には MyApiClient::Error を継承したクラスが指定可能です。my_api_client で標準で定義しているエラークラスについては以下のソースコードをご確認下さい。 raise を省略した場合は MyApiClient::Error を発生するようになります。

https://github.com/ryz310/my_api_client/blob/master/lib/my_api_client/errors

次に、 block を指定する場合について。

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

上記の例であれば、ステータスコードが 500..599 の場合に MyApiClient::ServerError を発生させる前に block の内容が実行れます。引数の params にはリクエスト情報とレスポンス情報が含まれています。logger はログ出力用インスタンスですが、このインスタンスを使ってログ出力すると、以下のようにリクエスト情報がログ出力に含まれるようになり、デバッグの際に便利です。

API request `GET https://example.com/path/to/resouce`: "Server error occurred."

json には Hash の Key に JSONPath を指定して、レスポンス JSON から任意の値を取得し、 Value とマッチするかどうかでエラーハンドリングできます。Value には String Integer Range Regexp が指定可能です。上記の場合であれば、以下のような JSON にマッチします。

error_handling json: { '$.errors.code': 10..19 }, with: :my_error_handling
{
  "erros": {
    "code": 10,
    "message": "Some error has occurred."
  }
}

headers には Hash の Key に レスポンスのヘッダーキーを指定して、 Value とマッチするかどうかでエラーハンドリングできます。Value には String Regexp が指定可能です。

error_handling headers: { 'www-authenticate': /invalid token/ }, with: :my_error_handling

上記の場合であれば、以下のような レスポンスヘッダー にマッチします。

cache-control: no-cache, no-store, max-age=0, must-revalidate
content-type: application/json
www-authenticate: Bearer error="invalid_token", error_description="invalid token"
content-length: 104

with にはインスタンスメソッド名を指定することで、エラーを検出した際、例外を発生させる前に任意のメソッドを実行させることができます。メソッドに渡される引数は block 定義の場合と同じく paramslogger です。なお、 blockwith は同時には利用できません。

# @param params [MyApiClient::Params::Params] HTTP req and res 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

Default error handling

my_api_client では、標準でステータスコード 400 ~ 500 番台のレスポンスを例外として処理するようにしています。ステータスコードが 400 番台場合は MyApiClient::ClientError、 500 番台の場合は MyApiClient::ServerError を継承した例外クラスが raise されます。

また、 MyApiClient::NetworkError に対しても標準で retry_on が定義されています。

いずれも override 可能ですので、必要に応じて error_handling を定義して下さい。

以下のファイルで定義しています。

https://github.com/ryz310/my_api_client/blob/master/lib/my_api_client/default_error_handlers.rb

Symbol を利用する

error_handling json: { '$.errors.code': :negative? }

実験的な機能ですが、statusjson の Value に Symbol を指定することで、結果値に対してメソッド呼び出しを行い、結果を判定させる事ができます。上記の場合、以下のような JSON にマッチします。なお、対象 Object に #negative? が存在しない場合はメソッドは呼び出されません。

{
  "erros": {
    "code": -1,
    "message": "Some error has occurred."
  }
}

forbid_nil

error_handling status_code: 200, json: :forbid_nil

一部のサービスではサーバーから何らかの Response Body が返ってくる事を期待しているにも関わらず、空の結果が結果が返ってくるというケースがあるようです。こちらも実験的な機能ですが、そういったケースを検出するために json: :forbid_nil オプションを用意しました。通常の場合、Response Body が空の場合はエラー判定をしませんが、このオプションを指定するとエラーとして検知する様になります。正常応答が空となる API も存在するので、誤検知にご注意下さい。

MyApiClient::Params::Params

WIP

MyApiClient::Error

API リクエストのレスポンスが error_handling で定義した matcher に合致した場合、 raise で指定した例外処理が発生します。この例外クラスは MyApiClient::Error を継承している必要があります。

この例外クラスには #params というメソッドが存在し、リクエストやレスポンスのパラメータを参照することが出来ます。

begin
  api_client.request
rescue MyApiClient::Error => e
  e.params.inspect
  # => {
  #      :request=>"#<MyApiClient::Params::Request#inspect>",
  #      :response=>"#<Sawyer::Response#inspect>",
  #    }
end

Bugsnag breadcrumbs

Bugsnag-Ruby v6.11.0 以降を利用している場合は breadcrumbs 機能 が自動的にサポートされます。この機能によって MyApiClient::Error 発生時に内部で Bugsnag.leave_breadcrumb が呼び出され、 Bugsnag のコンソールからエラー発生時のリクエスト情報、レスポンス情報などが確認できるようになります。

Retry

次に MyApiClient が提供するリトライ機能についてご紹介致します。

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

  retry_on MyApiClient::NetworkError, wait: 0.1.seconds, attempts: 3
  retry_on MyApiClient::ApiLimitError, wait: 30.seconds, attempts: 3

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

API リクエストを何度も実行していると回線の不調などによりネットワークエラーが発生する事があります。長時間ネットワークが使えなくなるケースもありますが、瞬間的なエラーであるケースも多々あります。 MyApiClient ではネットワーク系の例外はまとめて MyApiClient::NetworkError として raise されます。この例外の詳細は後述しますが、 retry_on を利用する事で、 ActiveJob のように任意の例外処理を補足して、一定回数、一定の期間を空けて API リクエストをリトライさせる事ができます。

なお、 retry_on MyApiClient::NetworkError は標準実装されているため、特別に定義せずとも自動的に適用されます。 waitattempts に任意の値を設定したい場合のみ定義してご利用ください。

ただし、 ActiveJob とは異なり同期処理でリトライするため、ネットワークの瞬断に備えたリトライ以外ではあまり使う機会はないのではないかと思います。上記の例のように API Rate Limit に備えてリトライするケースもあるかと思いますが、こちらは ActiveJob で対応した方が良いかもしれません。

ちなみに一応 discard_on も実装していますが、作者自身が有効な用途を見出せていないので、詳細は割愛します。良い利用方法があれば教えてください。

便利な使い方

error_handlingretry オプションを付与する事で retry_on の定義を省略できます。
例えば以下の 2 つのコードは同じ意味になります。

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

retry_onwaitattempts を指定する必要がない場合は retry: true という記述で動作します。

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

retry オプションを使用する際は以下の点に注意が必要です。

  • error_handlingraise オプションの指定が必須となります。
  • Block を使った error_handling の定義は禁止されます。

MyApiClient::NetworkError

前述の通りですが、 MyApiClient ではネットワーク系の例外はまとめて MyApiClient::NetworkError として raise されます。他の例外と同じく MyApiClient::Error を親クラスとしています。 MyApiClient::NetworkError として扱われる例外クラスの一覧は MyApiClient::NETWORK_ERRORS で参照できます。また、元となった例外は #original_error で参照できます。

begin
  api_client.request
rescue MyApiClient::NetworkError => e
  e.original_error # => #<Net::OpenTimeout>
  e.params.response # => nil
end

なお、通常の例外はリクエストの結果によって発生しますが、この例外はリクエスト中に発生するため、例外インスタンスにレスポンスパラメータは含まれません。

Timeout

WIP

Logger

WIP

One request for one class

多くの場合、同一ホストの API は リクエストヘッダーやエラー情報が同じ構造になっているため、上記のように一つのクラス内に複数の API を定義する設計が理にかなっていますが、 API 毎に個別に定義したい場合は、以下のように 1 つのクラスに 1 の API という構造で設計することも可能です。

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

  error_handling status_code: 400..599

  attr_reader :access_token

  def initialize(access_token:)
    @access_token = access_token
  end

  private

  def headers
    {
      'Content-Type': 'application/json;charset=UTF-8',
      'Authorization': "Bearer #{access_token}",
    }
  end
end

class GetUsersApiClient < ExampleApiClient
  error_handling json: { '$.errors.code': 10 }, raise: MyApiClient::ClientError

  # GET https://example.com/users
  #
  # @return [Sawyer::Resource] HTTP resource parameter
  def request
    get 'users', query: { key: 'value' }, headers: headers
  end
end

class PostUserApiClient < ExampleApiClient
  error_handling json: { '$.errors.code': 10 }, raise: MyApiClient::ApiLimitError

  # POST https://example.com/users
  #
  # @param name [String] Username which want to create
  # @return [Sawyer::Resource] HTTP resource parameter
  def request(name:)
    post 'users', headers: headers, body: { name: name }
  end
end

RSpec

Setup

RSpec を使ったテストをサポートしています。
以下のコードを spec/spec_helper.rb (または spec/rails_helper.rb) に追記して下さい。

require 'my_api_client/rspec'

Testing

以下のような ApiClient を定義しているとします。

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

  error_handling status_code: 200, json: { '$.errors.code': 10 },
                 raise: MyApiClient::ClientError

  attr_reader :access_token

  def initialize(access_token:)
    @access_token = access_token
  end

  # GET https://example.com/v1/users
  def get_users(condition:)
    get 'users', headers: headers, query: { search: condition }
  end

  private

  def headers
    {
      'Content-Type': 'application/json;charset=UTF-8',
      'Authorization': "Bearer #{access_token}",
    }
  end
end

通常の場合 ApiClient を新たに定義した際にテストすべき項目が 2 つあります。

  1. 指定したエンドポイントに対して、任意のパラメータを使ったリクエストが実行されること
  2. 特定のレスポンスに対して適切にエラーハンドリングが実行されること

my_api_client ではこれらをテストするための Custom Matcher を用意しています。

1. 指定したエンドポイントに対して、任意のパラメータを使ったリクエストが実行されること

例えば上述の #get_users の内部では、入力引数を用いて検索クエリが組み立てられていたり、 Header に access_token を利用したりしています。これらの値が正しくリクエストに用いられているかどうかのテストが必要となります。

この場合 request_towith という Custom Matcher を利用することで簡単にテストを記述することが出来ます。 expect にはブロック {} を指定する必要がある点にご注意ください。他にも with には body というキーワード引数も指定できます。

RSpec.describe ExampleApiClient, type: :api_client do
  let(:api_client) { described_class.new(access_token: 'access token') }
  let(:headers) do
    {
      'Content-Type': 'application/json;charset=UTF-8',
      'Authorization': 'Bearer access token',
    }
  end

  describe '#get_users' do
    it do
      expect { api_client.get_users(condition: 'condition') }
        .to request_to(:get, 'https://example.com/v1/users')
        .with(headers: headers, query: { condition: 'condition' })
    end
  end
end

2. 特定のレスポンスに対して適切にエラーハンドリングが実行されること

次に error_handling についてのテストも記述していきます。ここではレスポンスのステータスコードが 200 かつ Body に '$.errors.code': 10 という値が含まれていた場合は MyApiClient::ClientErrorraise する、というエラーハンドリングが定義されています。

ここでは be_handled_as_an_errorwhen_receive という Custom Matcher を利用します。ここでも expect にはブロック {} を指定する必要がある点にご注意ください。

be_handled_as_an_error の引数には期待する例外クラスを指定します。 when_receive にはリクエスト結果としてどのような値が返ってきたのかを指定します。

なお、 error_handling で例外を発生させないケースは現在想定していないため、これ以外の Custom Matcher は定義されていません。何かユースケースがあれば教えて下さい。

it do
  expect { api_client.get_users(condition: 'condition') }
    .to be_handled_as_an_error(MyApiClient::ClientError)
    .when_receive(status_code: 200, body: { errors: { code: 10 } }.to_json)
end

また、以下のように正常なレスポンスが返ってきた時に誤ってエラーハンドリングされていないかをテストすることもできます。

it do
  expect { api_client.get_users(condition: 'condition') }
    .not_to be_handled_as_an_error(MyApiClient::ClientError)
    .when_receive(status_code: 200, body: { users: { id: 1 } }.to_json)
end
retry_on を定義している場合

以下のように retry_on を API Client に定義している場合:

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

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

after_retrytimes という Custom Matcher を利用することが出来ます。

it do
  expect { api_client.get_users(condition: 'condition') }
    .to be_handled_as_an_error(MyApiClient::ApiLimitError)
    .after_retry(3).times
    .when_receive(status_code: 200, body: { errors: { code: 20 } }.to_json)
end

Stubbing

response option

以下のような ApiClient を定義しているとします。

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

  def request(user_id:)
    get "users/#{user_id}"
  end
end

stub_api_client_allstub_api_client を使うことで、 ExampleApiClient#request をスタブ化することができます。これで #request を実行してもリアルな HTTP リクエストが実行されなくなります。

stub_api_client_all(
  ExampleApiClient,
  request: { response: { id: 12345 } }
)

response = ExampleApiClient.new.request(user_id: 1)
response.id # => 12345

response は省略することも可能です。

stub_api_client_all(
  ExampleApiClient,
  request: { id: 12345 }
)

response = ExampleApiClient.new.request(user_id: 1)
response.id # => 12345

Proc

リクスエストパラメータを使ったレスポンスを返すようにスタブ化したい場合は、 Proc を利用することで実現できます。

stub_api_client_all(
  ExampleApiClient,
  request: ->(params) { { id: params[:user_id] } }
)

response = ExampleApiClient.new.request(user_id: 1)
response.id # => 1

Return value of #stub_api_client_all and #stub_api_client

#stub_api_client_all#stub_api_client の戻り値はスタブ化した API Client のスタブオブジェクトです。receivehave_received を使ったテストを書きたい場合は、これらの値を利用すると良いでしょう。

def execute_api_request
  ExampleApiClient.new.request(user_id: 1)
end

api_clinet = stub_api_client_all(ExampleApiClient, request: nil)
execute_api_request
expect(api_client).to have_received(:request).with(user_id: 1)

raise option

例外が発生する場合のテストを書きたい場合は、 raise オプションを利用することができます。

def execute_api_request
  ExampleApiClient.new.request(user_id: 1)
end

stub_api_client_all(ExampleApiClient, request: { raise: MyApiClient::Error })
expect { execute_api_request }.to raise_error(MyApiClient::Error)

なお、発生した例外に含まれるレスポンスパラメータやステータスコードもスタブ化したい場合は、 response オプションと同時に指定することが可能です。

stub_api_client_all(
  ExampleApiClient,
  request: {
    raise: MyApiClient::Error,
    response: { message: 'error' },
    status_code: 429
  }
)

begin
  ExampleApiClient.new.request(user_id: 1)
rescue MyApiClient::Error => e
  response_body = e.params.response.data.to_h
  expect(response_body).to eq(message: 'error')
  status_code = e.params.response.status
  expect(status_code).to eq(429)
end

pageable option

#pageable_get (#pget) を使った実装用に pageable というオプションが利用できます。
pageable に設定する値は Enumerable である必要があります。

stub_api_client_all(
  MyPaginationApiClient,
  pagination: {
    pageable: [
      { page: 1 },
      { page: 2 },
      { page: 3 },
    ],
  }
)

MyPaginationApiClient.new.pagination.each do |response|
  response.page #=> 1, 2, 3
end

なお、 Enumerable の各値にはここまで紹介した response, raise, Proc など全てのオプションが利用可能です。

stub_api_client_all(
  MyPaginationApiClient,
  pagination: {
    pageable: [
      { response: { page: 1 } },
      { page: 2 },
      ->(params) { { page: 3, user_id: params[:user_id] } },
      { raise: MyApiClient::ClientError::IamTeapot },
    ],
  }
)

また、 Enumerator を使えば無限に続くページネーションを定義することもできます。

stub_api_client_all(
  MyPaginationApiClient,
  pagination: {
    pageable: Enumerator.new do |y|
      loop.with_index(1) do |_, i|
        y << { page: i }
      end
    end,
  }
)

Deployment

この gem のリリースには gem_comet を利用しています。
gem_cometREADME.md にも使い方が載っていますが、備忘録のため、こちらにもリリースフローを記載しておきます。

Preparement

以下のコマンドで .envrc を作成し、 GITHUB_ACCESS_TOKEN を設定します。

$ cp .envrc.skeleton .envrc

以下のコマンドで gem_comet をインストールします。

$ gem install gem_comet

USAGE

以下のコマンドで、最後のリリースから現在までに merge した PR の一覧を確認できます。

$ gem_comet changelog

以下のコマンドで gem のリリースを実行します。
{VERSION} には新しく付与するバージョン番号を指定します。

$ gem_comet release {VERSION}

実行すると、 https://github.com/ryz310/my_api_client/pulls に以下のような PR が作成されます。

まず、 Update v{VERSION} という PR から merge に取り掛かります。

PR のコメントにも TODO が記載されていますが、まず、バージョン番号が正しく採番されているかを確認します。

See: 314a4c0

次に CHANGELOG.md を編集して、 CHANGELOG を見やすく整理します。

See: 33a2d17

これらが整ったら、 Update v{VERSION} を merge します。

これでリリース準備が整ったので、Release v{VERSION} の merge に取り掛かります。

この PR にこれからリリースする gem に対する変更が全て載っています。
変更内容の最終確認をして、 CI も通ったことを確認したら Release v{VERSION} を merge します。

あとは Circle CI 側で gem のリリースが自動実行されるので、暫く待ちましょう。

お疲れさまでした 🍵

Contributing

不具合の報告や Pull Request を歓迎しています。OSS という事で自分はなるべく頑張って英語を使うようにしていますが、日本語での報告でも大丈夫です 👍