diff --git a/README.jp.md b/README.jp.md index dbe3f591..e342d9a3 100644 --- a/README.jp.md +++ b/README.jp.md @@ -1,785 +1,70 @@ -[![CircleCI](https://circleci.com/gh/ryz310/my_api_client.svg?style=svg)](https://circleci.com/gh/ryz310/my_api_client) [![Gem Version](https://badge.fury.io/rb/my_api_client.svg)](https://badge.fury.io/rb/my_api_client) [![Maintainability](https://api.codeclimate.com/v1/badges/861a2c8f168bbe995107/maintainability)](https://codeclimate.com/github/ryz310/my_api_client/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/861a2c8f168bbe995107/test_coverage)](https://codeclimate.com/github/ryz310/my_api_client/test_coverage) [![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=ryz310/my_api_client)](https://dependabot.com) +[![CircleCI](https://circleci.com/gh/ryz310/my_api_client.svg?style=svg)](https://circleci.com/gh/ryz310/my_api_client) +[![Gem Version](https://badge.fury.io/rb/my_api_client.svg)](https://badge.fury.io/rb/my_api_client) +[![Maintainability](https://api.codeclimate.com/v1/badges/861a2c8f168bbe995107/maintainability)](https://codeclimate.com/github/ryz310/my_api_client/maintainability) +[![Test Coverage](https://api.codeclimate.com/v1/badges/861a2c8f168bbe995107/test_coverage)](https://codeclimate.com/github/ryz310/my_api_client/test_coverage) -# MyApiClient - -MyApiClient は API リクエストクラスを作成するための汎用的な機能を提供します。[Sawyer](https://github.com/lostisland/sawyer) や [Faraday](https://github.com/lostisland/faraday) をベースにエラーハンドリングの機能を強化した構造になっています。 - -ただし、 Sawyer はダミーデータの作成が難しかったり、他の gem で競合することがよくあるので、将来的には依存しないように変更していくかもしれません。 +English docs: [README.md](README.md) -また、 Ruby on Rails で利用することを想定してますが、それ以外の環境でも動作するように作っています。不具合などあれば Issue ページからご報告下さい。 +# MyApiClient -[toc] +`my_api_client` は Ruby on Rails / Ruby 向けの API クライアントビルダーです。 +Sawyer + Faraday をベースに、次の機能を提供します。 -## Supported Versions +- リクエスト DSL (`get`, `post`, `patch`, `put`, `delete`, `pageable_get`) +- レスポンス内容で判定できるエラーハンドリング DSL (`error_handling`) +- リトライ/破棄フック (`retry_on`, `discard_on`) +- RSpec 用のスタブ/カスタムマッチャ -- Ruby 3.1, 3.2, 3.3 -- Rails 6.1, 7.0, 7.1, 7.2 +## 対応バージョン -## Installation +- Ruby: 3.1, 3.2, 3.3 +- Rails: 6.1, 7.0, 7.1, 7.2 -`my_api_client` を Gemfile に追加して下さい: +## インストール ```ruby gem 'my_api_client' ``` -Ruby on Rails を利用している場合は `generator` 機能を利用できます。 +Rails の場合は generator でひな形を作れます。 ```sh -$ 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 +rails g api_client path/to/resource get:path/to/resource --endpoint https://example.com ``` -## Usage - -### Basic - -最もシンプルな利用例を以下に示します。 +## クイックスタート ```ruby 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 #=> # -``` - -クラス定義の最初に記述される `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` というメソッドを用意しています。以下に例を示します。 - -```ruby -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"` を示します。 - -```json -{ - "links": { - "next": "https://example.com/pagination?page=3", - "previous": "https://example.com/pagination?page=1" - }, - "page": 2 -} -``` - -そして `#pageable_get` は [Enumerator::Lazy](https://docs.ruby-lang.org/ja/latest/class/Enumerator=3a=3aLazy.html) を返すので、 `#each` や `#next` を実行することで次の結果を取得できます。 - -```ruby -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` も利用可能です。 - -```ruby -# GET pagination?page=1 -def pagination - pget 'pagination', paging: '$.links.next', headers: headers, query: { page: 1 } -end -``` - -### Error handling - -`my_api_client` ではレスポンスの内容によって例外を発生させるエラーハンドリングを定義できます。ここでは例として前述のコードにエラーハンドリングを定義しています。 - -```ruby -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` を指定するものについて。 - -```ruby -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` を指定する場合について。 - -```ruby -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` はログ出力用インスタンスですが、このインスタンスを使ってログ出力すると、以下のようにリクエスト情報がログ出力に含まれるようになり、デバッグの際に便利です。 - -```text -API request `GET https://example.com/path/to/resouce`: "Server error occurred." -``` - -`json` には `Hash` の Key に [JSONPath](https://goessner.net/articles/JsonPath/) を指定して、レスポンス JSON から任意の値を取得し、 Value とマッチするかどうかでエラーハンドリングできます。Value には `String` `Integer` `Range` `Regexp` が指定可能です。上記の場合であれば、以下のような JSON にマッチします。 - -```ruby -error_handling json: { '$.errors.code': 10..19 }, with: :my_error_handling -``` - -```json -{ - "erros": { - "code": 10, - "message": "Some error has occurred." - } -} -``` - -`headers` には `Hash` の Key に レスポンスのヘッダーキーを指定して、 Value とマッチするかどうかでエラーハンドリングできます。Value には `String` `Regexp` が指定可能です。 - -```ruby -error_handling headers: { 'www-authenticate': /invalid token/ }, with: :my_error_handling -``` - -上記の場合であれば、以下のような レスポンスヘッダー にマッチします。 - -```text -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` 定義の場合と同じく `params` と `logger` です。なお、 `block` と `with` は同時には利用できません。 - -```ruby -# @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 を利用する - -```ruby -error_handling json: { '$.errors.code': :negative? } -``` - -実験的な機能ですが、`status` や `json` の Value に `Symbol` を指定することで、結果値に対してメソッド呼び出しを行い、結果を判定させる事ができます。上記の場合、以下のような JSON にマッチします。なお、対象 Object に `#negative?` が存在しない場合はメソッドは呼び出されません。 - -```json -{ - "erros": { - "code": -1, - "message": "Some error has occurred." - } -} -``` - -#### forbid_nil - -```ruby -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` というメソッドが存在し、リクエストやレスポンスのパラメータを参照することが出来ます。 - -```ruby -begin - api_client.request -rescue MyApiClient::Error => e - e.params.inspect - # => { - # :request=>"#", - # :response=>"#", - # } -end -``` - -#### Bugsnag breadcrumbs - -[Bugsnag-Ruby v6.11.0](https://github.com/bugsnag/bugsnag-ruby/releases/tag/v6.11.0) 以降を利用している場合は [breadcrumbs 機能](https://docs.bugsnag.com/platforms/ruby/other/#logging-breadcrumbs) が自動的にサポートされます。この機能によって `MyApiClient::Error` 発生時に内部で `Bugsnag.leave_breadcrumb` が呼び出され、 Bugsnag のコンソールからエラー発生時のリクエスト情報、レスポンス情報などが確認できるようになります。 - -### Retry - -次に `MyApiClient` が提供するリトライ機能についてご紹介致します。 - -```ruby -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` は標準実装されているため、特別に定義せずとも自動的に適用されます。 `wait` や `attempts` に任意の値を設定したい場合のみ定義してご利用ください。 - -ただし、 `ActiveJob` とは異なり同期処理でリトライするため、ネットワークの瞬断に備えたリトライ以外ではあまり使う機会はないのではないかと思います。上記の例のように API Rate Limit に備えてリトライするケースもあるかと思いますが、こちらは `ActiveJob` で対応した方が良いかもしれません。 - -ちなみに一応 `discard_on` も実装していますが、作者自身が有効な用途を見出せていないので、詳細は割愛します。良い利用方法があれば教えてください。 - -#### 便利な使い方 - -`error_handling` に `retry` オプションを付与する事で `retry_on` の定義を省略できます。 -例えば以下の 2 つのコードは同じ意味になります。 - -```ruby -retry_on MyApiClient::ApiLimitError, wait: 30.seconds, attempts: 3 -error_handling json: { '$.errors.code': 20 }, - raise: MyApiClient::ApiLimitError -``` - -```ruby -error_handling json: { '$.errors.code': 20 }, - raise: MyApiClient::ApiLimitError, - retry: { wait: 30.seconds, attempts: 3 } -``` - -`retry_on` で `wait` や `attempts` を指定する必要がない場合は `retry: true` という記述で動作します。 - -```ruby -error_handling json: { '$.errors.code': 20 }, - raise: MyApiClient::ApiLimitError, - retry: true -``` - -`retry` オプションを使用する際は以下の点に注意が必要です。 - -- `error_handling` に `raise` オプションの指定が必須となります。 -- Block を使った `error_handling` の定義は禁止されます。 - -#### MyApiClient::NetworkError - -前述の通りですが、 `MyApiClient` ではネットワーク系の例外はまとめて `MyApiClient::NetworkError` として `raise` されます。他の例外と同じく `MyApiClient::Error` を親クラスとしています。 `MyApiClient::NetworkError` として扱われる例外クラスの一覧は `MyApiClient::NETWORK_ERRORS` で参照できます。また、元となった例外は `#original_error` で参照できます。 - -```ruby -begin - api_client.request -rescue MyApiClient::NetworkError => e - e.original_error # => # - e.params.response # => nil -end -``` - -なお、通常の例外はリクエストの結果によって発生しますが、この例外はリクエスト中に発生するため、例外インスタンスにレスポンスパラメータは含まれません。 - -### Timeout - -WIP - -### Logger - -WIP - -## One request for one class - -多くの場合、同一ホストの API は リクエストヘッダーやエラー情報が同じ構造になっているため、上記のように一つのクラス内に複数の API を定義する設計が理にかなっていますが、 API 毎に個別に定義したい場合は、以下のように 1 つのクラスに 1 の API という構造で設計することも可能です。 - -```ruby -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`) に追記して下さい。 - -```ruby -require 'my_api_client/rspec' -``` - -### Testing - -以下のような `ApiClient` を定義しているとします。 - -```ruby -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_to` と `with` という Custom Matcher を利用することで簡単にテストを記述することが出来ます。 `expect` にはブロック `{}` を指定する必要がある点にご注意ください。他にも `with` には `body` というキーワード引数も指定できます。 - -```ruby -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::ClientError` を `raise` する、というエラーハンドリングが定義されています。 - -ここでは `be_handled_as_an_error` と `when_receive` という Custom Matcher を利用します。ここでも `expect` にはブロック `{}` を指定する必要がある点にご注意ください。 - -`be_handled_as_an_error` の引数には期待する例外クラスを指定します。 `when_receive` にはリクエスト結果としてどのような値が返ってきたのかを指定します。 - -なお、 `error_handling` で例外を発生させないケースは現在想定していないため、これ以外の Custom Matcher は定義されていません。何かユースケースがあれば教えて下さい。 - -```ruby -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 -``` - -また、以下のように正常なレスポンスが返ってきた時に誤ってエラーハンドリングされていないかをテストすることもできます。 - -```ruby -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 に定義している場合: - -```ruby -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_retry` と `times` という Custom Matcher を利用することが出来ます。 - -```ruby -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` を定義しているとします。 - -```ruby -class ExampleApiClient < MyApiClient::Base - endpoint 'https://example.com' - - def request(user_id:) - get "users/#{user_id}" + get 'users', query: { search: condition } end end -``` - -`stub_api_client_all` や `stub_api_client` を使うことで、 `ExampleApiClient#request` をスタブ化することができます。これで `#request` を実行してもリアルな HTTP リクエストが実行されなくなります。 - -```ruby -stub_api_client_all( - ExampleApiClient, - request: { response: { id: 12345 } } -) - -response = ExampleApiClient.new.request(user_id: 1) -response.id # => 12345 -``` - -`response` は省略することも可能です。 - -```ruby -stub_api_client_all( - ExampleApiClient, - request: { id: 12345 } -) - -response = ExampleApiClient.new.request(user_id: 1) -response.id # => 12345 -``` - -#### Proc - -リクスエストパラメータを使ったレスポンスを返すようにスタブ化したい場合は、 `Proc` を利用することで実現できます。 - -```ruby -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 のスタブオブジェクトです。`receive` や `have_received` を使ったテストを書きたい場合は、これらの値を利用すると良いでしょう。 - -```ruby -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` オプションを利用することができます。 - -```ruby -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` オプションと同時に指定することが可能です。 - -```ruby -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` である必要があります。 - -```ruby -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` など全てのオプションが利用可能です。 - -```ruby -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` を使えば無限に続くページネーションを定義することもできます。 - -```ruby -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](https://github.com/ryz310/gem_comet) を利用しています。 -`gem_comet` の README.md にも使い方が載っていますが、備忘録のため、こちらにもリリースフローを記載しておきます。 - -### Preparement - -以下のコマンドで `.envrc` を作成し、 `GITHUB_ACCESS_TOKEN` を設定します。 - -```sh -$ cp .envrc.skeleton .envrc -``` - -以下のコマンドで `gem_comet` をインストールします。 -```sh -$ gem install gem_comet +api_client = ExampleApiClient.new +api_client.get_users(condition: 'john') ``` -### USAGE - -以下のコマンドで、最後のリリースから現在までに merge した PR の一覧を確認できます。 +## ドキュメント -```sh -$ gem_comet changelog -``` - -以下のコマンドで gem のリリースを実行します。 -`{VERSION}` には新しく付与するバージョン番号を指定します。 - -```sh -$ gem_comet release {VERSION} -``` +- [はじめに](docs/ja/01-getting-started.md) +- [基本的な使い方とページネーション](docs/ja/02-usage.md) +- [エラーハンドリング](docs/ja/03-error-handling.md) +- [リトライ・タイムアウト・ロガー](docs/ja/04-retry-timeout-logger.md) +- [RSpec ヘルパーとマッチャ](docs/ja/05-rspec.md) +- [開発とリリース](docs/ja/06-development.md) -実行すると、 https://github.com/ryz310/my_api_client/pulls に以下のような PR が作成されます。 - -- [Update v0\.16\.1](https://github.com/ryz310/my_api_client/pull/297) -- [Release v0\.16\.1](https://github.com/ryz310/my_api_client/pull/298) - -まず、 `Update v{VERSION}` という PR から merge に取り掛かります。 - -PR のコメントにも TODO が記載されていますが、まず、バージョン番号が正しく採番されているかを確認します。 - -See: [314a4c0](https://github.com/ryz310/my_api_client/pull/297/commits/314a4c06f66324ce77b640b1ee8db5c84ee038a2) - -次に `CHANGELOG.md` を編集して、 CHANGELOG を見やすく整理します。 - -See: [33a2d17](https://github.com/ryz310/my_api_client/pull/297/commits/33a2d1703c773813c837e74ee3181906b2f2e502) - -これらが整ったら、 `Update v{VERSION}` を merge します。 - -これでリリース準備が整ったので、`Release v{VERSION}` の merge に取り掛かります。 +## Contributing -この PR にこれからリリースする gem に対する変更が全て載っています。 -変更内容の最終確認をして、 CI も通ったことを確認したら `Release v{VERSION}` を merge します。 +不具合報告・PR は歓迎です。 + -あとは Circle CI 側で gem のリリースが自動実行されるので、暫く待ちましょう。 +## License -お疲れさまでした :tea: +[MIT License](https://opensource.org/licenses/MIT) です。 -## Contributing +## Code of Conduct -不具合の報告や Pull Request を歓迎しています。OSS という事で自分はなるべく頑張って英語を使うようにしていますが、日本語での報告でも大丈夫です :+1: +[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) をご確認ください。 diff --git a/README.md b/README.md index 0b1a35f4..1507961a 100644 --- a/README.md +++ b/README.md @@ -2,448 +2,69 @@ [![Gem Version](https://badge.fury.io/rb/my_api_client.svg)](https://badge.fury.io/rb/my_api_client) [![Maintainability](https://api.codeclimate.com/v1/badges/861a2c8f168bbe995107/maintainability)](https://codeclimate.com/github/ryz310/my_api_client/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/861a2c8f168bbe995107/test_coverage)](https://codeclimate.com/github/ryz310/my_api_client/test_coverage) -![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/ryz310/my_api_client) -日本語ドキュメントは [こちら](README.jp.md) +日本語ドキュメントは [README.jp.md](README.jp.md) # MyApiClient -This gem is an API client builder. It provides generic functionality for creating API request classes. It has a structure based on [Sawyer](https://github.com/lostisland/sawyer) and [Faraday](https://github.com/lostisland/faraday) with enhanced error handling functions. +`my_api_client` is a Ruby API client builder for Rails and plain Ruby apps. +It is based on Sawyer + Faraday and adds: -It is supposed to be used in Ruby on Rails, but it is made to work in other environments. If you have any problems, please report them from the Issue page. - -[toc] +- easy request DSL (`get`, `post`, `patch`, `put`, `delete`, `pageable_get`) +- flexible response-based error handling DSL (`error_handling`) +- retry/discard hooks (`retry_on`, `discard_on`) +- RSpec helpers and custom matchers for testing ## Supported Versions -- Ruby 3.1, 3.2, 3.3 -- Rails 6.1, 7.0, 7.1, 7.2 +- Ruby: 3.1, 3.2, 3.3 +- Rails: 6.1, 7.0, 7.1, 7.2 ## Installation -Add this line to your application's Gemfile: - ```ruby gem 'my_api_client' ``` -If you are using Ruby on Rails, you can use the `generator` function. +For Rails, you can generate a starter class: ```sh -$ 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 - -The simplest usage example is shown below: - -```ruby -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 #=> # -``` - -The `endpoint` defines the intersection of the request URL. Each method described below defines a subsequent path. In the above example, `get 'users'` will request to `GET https://example.com/v1/users`. - -Next, define `#initialize`. Suppose you want to set an Access Token, API Key, etc. as in the example above. You can omit the definition if you don't need it. - -Then define `#get_users` and `#post_user`. It's a good idea to give the method name the title of the API. I'm calling `#get` and `#post` inside the method, which is the HTTP Method at the time of the request. You can also use `#patch` `#put` `#delete`. - -### Pagination - -Some APIs include a URL in the response to get the continuation of the result. - -MyApiClient provides a method called `#pageable_get` to handle such APIs as enumerable. An example is shown below: - -```ruby -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 -``` - -In the above example, the request is first made for `GET https://example.com/v1/pagination?page=1`, followed by the URL contained in the response JSON `$.link.next`. Make a request to enumerable. - -For example, in the following response, `$.link.next` indicates `"https://example.com/pagination?page=3"`: - -```json -{ - "links": { - "next": "https://example.com/pagination?page=3", - "previous": "https://example.com/pagination?page=1" - }, - "page": 2 -} -``` - -`#pageable_get` returns [Enumerator::Lazy](https://docs.ruby-lang.org/ja/latest/class/Enumerator=3a=3aLazy.html), so you can get the following result by `#each` or `#next`: - -```ruby -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 -``` - -Note that `#each` is repeated until the value of `paging` becomes `nil`. You can set the upper limit of pagination by combining with `#take`. - -You can also use `#pget` as an alias for `#pageable_get`: - -```ruby -# GET pagination?page=1 -def pagination - pget 'pagination', paging: '$.links.next', headers: headers, query: { page: 1 } -end -``` - -### Error handling - -MyApiClient allows you to define error handling that raises an exception depending on the content of the response. Here, as an example, error handling is defined in the above code: - -```ruby -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 -``` - -I will explain one by one. First, about the one that specifies `status_code` as follows: - -```ruby -error_handling status_code: 400..499, raise: MyApiClient::ClientError -``` - -This will cause `MyApiClient::ClientError` to occur as an exception if the status code of the response is `400..499` for all requests from `ExampleApiClient`. Error handling also applies to classes that inherit from `ExampleApiClient`. - -Note that `Integer` `Range`, and `Regexp` can be specified for `status_code`. - -A class that inherits `MyApiClient::Error` can be specified for `raise`. Please check [here](https://github.com/ryz310/my_api_client/blob/master/lib/my_api_client/errors) for the error class defined as standard in `my_api_client`. If `raise` is omitted, `MyApiClient::Error` will be raised. - -Next, about the case of specifying `block`: - -```ruby -error_handling status_code: 500..599, raise: MyApiClient::ServerError do |_params, logger| - logger.warn 'Server error occurred.' -end -``` - -In the above example, if the status code is `500..599`, the contents of `block` will be executed before raising `MyApiClient::ServerError`. The argument `params` contains request and response information. - -`logger` is an instance for log output. If you log output using this instance, the request information will be included in the log output as shown below, which is convenient for debugging: - -```text -API request `GET https://example.com/path/to/resouce`: "Server error occurred." -``` - -```ruby -error_handling json: { '$.errors.code': 10..19 }, with: :my_error_handling -``` - -For `json`, specify [JSONPath](https://goessner.net/articles/JsonPath/) for the Key of `Hash`, get an arbitrary value from the response JSON, and check whether it matches value. You can handle errors. You can specify `String` `Integer` `Range` and `Regexp` for value. - -In the above case, it matches JSON as below: - -```json -{ - "erros": { - "code": 10, - "message": "Some error has occurred." - } -} -``` - -For `headers`, specify response header for the Key of `Hash`, get an arbitrary value from the response header, and check whether it matches value. You can handle errors. You can specify `String` and `Regexp` for value. - -```ruby -error_handling headers: { 'www-authenticate': /invalid token/ }, with: :my_error_handling -``` - -In the above case, it matches response header as below: - -```text -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 -``` - -By specifying the instance method name in `with`, when an error is detected, any method can be executed before raising an exception. The arguments passed to the method are `params` and `logger` as in the `block` definition. Note that `block` and` with` cannot be used at the same time. - -```ruby -# @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 - -In MyApiClient, the response of status code 400 ~ 500 series is handled as an exception by default. If the status code is in the 400s, an exception class that inherits `MyApiClient::ClientError` is raised, and in the 500s, an exception class that inherits `MyApiClient::ServerError` is raised. - -Also, `retry_on` is defined by default for `MyApiClient::NetworkError`. - -Both can be overridden, so define `error_handling` as needed. - -They are defined [here](https://github.com/ryz310/my_api_client/blob/master/lib/my_api_client/default_error_handlers.rb). - -#### Use Symbol - -```ruby -error_handling json: { '$.errors.code': :negative? } -``` - -Although it is an experimental function, by specifying `Symbol` for value of `status` or `json`, you can call a method for the result value and judge the result. In the above case, it matches the following JSON. If `#negative?` does not exist in the target object, the method will not be called. - -#### forbid_nil - -```ruby -error_handling status_code: 200, json: :forbid_nil -``` - -It seems that some services expect an empty Response Body to be returned from the server, but an empty result is returned. This is also an experimental feature, but we have provided the `json: :forbid_nil` option to detect such cases. Normally, if the response body is empty, no error judgment is made, but if this option is specified, it will be detected as an error. Please be careful about false positives because some APIs have an empty normal response. - -#### MyApiClient::Params::Params - -WIP - -#### MyApiClient::Error - -If the response of the API request matches the matcher defined in `error_handling`, the exception handling specified in `raise` will occur. This exception class must inherit `MyApiClient::Error`. - -This exception class has a method called `#params`, which allows you to refer to request and response parameters. - -```ruby -begin - api_client.request -rescue MyApiClient::Error => e - e.params.inspect - # => { - # :request=>"#", - # :response=>"#", - # } -end -``` - -#### Bugsnag breadcrumbs - -If you are using [Bugsnag-Ruby v6.11.0](https://github.com/bugsnag/bugsnag-ruby/releases/tag/v6.11.0) or later, [breadcrumbs function](https://docs. bugsnag.com/platforms/ruby/other/#logging-breadcrumbs) is automatically supported. With this function, `Bugsnag.leave_breadcrumb` is called internally when `MyApiClient::Error` occurs, and you can check the request information, response information, etc. when an error occurs from the Bugsnag console. - -### Retry - -Next, I would like to introduce the retry function provided by MyApiClient. - -```ruby -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 -``` - -If the API request is executed many times, a network error may occur due to a line malfunction. In some cases, the network will be unavailable for a long time, but in many cases it will be a momentary error. In MyApiClient, network exceptions are collectively raised as `MyApiClient::NetworkError`. The details of this exception will be described later, but by using `retry_on`, it is possible to supplement arbitrary exception handling like `ActiveJob` and retry the API request a certain number of times and after a certain period of time. - -Note that `retry_on MyApiClient::NetworkError` is implemented as standard, so it will be applied automatically without any special definition. Please define and use it only when you want to set an arbitrary value for `wait` or `attempts`. - -However, unlike `ActiveJob`, it retries in synchronous processing, so I think that there is not much opportunity to use it other than retrying in case of a momentary network interruption. As in the above example, there may be cases where you retry in preparation for API Rate Limit, but it may be better to handle this with `ActiveJob`. - -By the way, `discard_on` is also implemented, but since the author himself has not found an effective use, I will omit the details. Please let me know if there is a good way to use it. - -#### Convenient usage - -You can omit the definition of `retry_on` by adding the `retry` option to `error_handling`. -For example, the following two codes have the same meaning: - -```ruby -retry_on MyApiClient::ApiLimitError, wait: 30.seconds, attempts: 3 -error_handling json: { '$.errors.code': 20 }, - raise: MyApiClient::ApiLimitError -``` - -```ruby -error_handling json: { '$.errors.code': 20 }, - raise: MyApiClient::ApiLimitError, - retry: { wait: 30.seconds, attempts: 3 } -``` - -If you do not need to specify `wait` or` attempts` in `retry_on`, it works with `retry: true`: - -```ruby -error_handling json: { '$.errors.code': 20 }, - raise: MyApiClient::ApiLimitError, - retry: true +rails g api_client path/to/resource get:path/to/resource --endpoint https://example.com ``` -Keep the following in mind when using the `retry` option: - -- The `raise` option must be specified for `error_handling` -- Definition of `error_handling` using `block` is prohibited - -#### MyApiClient::NetworkError - -As mentioned above, in MyApiClient, network exceptions are collectively `raised` as `MyApiClient::NetworkError`. Like the other exceptions, it has `MyApiClient::Error` as its parent class. A list of exception classes treated as `MyApiClient::NetworkError` can be found in `MyApiClient::NETWORK_ERRORS`. You can also refer to the original exception with `#original_error`: - -```ruby -begin - api_client.request -rescue MyApiClient::NetworkError => e - e.original_error # => # - e.params.response # => nil -end -``` - -Note that a normal exception is raised depending on the result of the request, but since this exception is raised during the request, the exception instance does not include the response parameter. - -### Timeout - -WIP - -### Logger - -WIP - -## RSpec - -### Setup - -Supports testing with RSpec. -Add the following code to `spec/spec_helper.rb` (or `spec/rails_helper.rb`): - -```ruby -require 'my_api_client/rspec' -``` - -### Testing - -Suppose you have defined a `ApiClient` like this: +## Quick Start ```ruby 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}", - } + get 'users', query: { search: condition } end end -``` - -WIP - -### Stubbing -WIP - -## Development +api_client = ExampleApiClient.new +api_client.get_users(condition: 'john') +``` -After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. +## Docs -To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). +- [Getting Started](docs/en/01-getting-started.md) +- [Basic Usage and Pagination](docs/en/02-usage.md) +- [Error Handling](docs/en/03-error-handling.md) +- [Retry, Timeout, Logger](docs/en/04-retry-timeout-logger.md) +- [RSpec Helpers and Matchers](docs/en/05-rspec.md) +- [Development and Release](docs/en/06-development.md) ## Contributing -Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/my_api_client. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. +Bug reports and pull requests are welcome on GitHub: + ## License -The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). +Available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). ## Code of Conduct -Everyone interacting in the MyApiClient project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/my_api_client/blob/master/CODE_OF_CONDUCT.md). +Please follow the [code of conduct](CODE_OF_CONDUCT.md). diff --git a/docs/en/01-getting-started.md b/docs/en/01-getting-started.md new file mode 100644 index 00000000..596eb9a1 --- /dev/null +++ b/docs/en/01-getting-started.md @@ -0,0 +1,29 @@ +# Getting Started + +## Installation + +```ruby +gem 'my_api_client' +``` + +## Rails Generator + +```sh +rails g api_client path/to/resource get:path/to/resource --endpoint https://example.com +``` + +This generates: + +- `app/api_clients/application_api_client.rb` +- `app/api_clients/path/to/resource_api_client.rb` +- `spec/api_clients/path/to/resource_api_client_spec.rb` + +## Minimal Class + +```ruby +class ApplicationApiClient < MyApiClient::Base + endpoint 'https://example.com/v1' +end +``` + +Define shared endpoint, logger, and common error handling in this base class. diff --git a/docs/en/02-usage.md b/docs/en/02-usage.md new file mode 100644 index 00000000..a9b54707 --- /dev/null +++ b/docs/en/02-usage.md @@ -0,0 +1,47 @@ +# Basic Usage and Pagination + +## Basic Request DSL + +```ruby +class ExampleApiClient < MyApiClient::Base + endpoint 'https://example.com/v1' + + def get_users(condition:) + get 'users', query: { search: condition } + end + + def post_user(name:) + post 'users', body: { name: name } + end +end +``` + +Available methods: + +- `get` +- `post` +- `put` +- `patch` +- `delete` + +Each method returns `response.data` (`Sawyer::Resource`) unless you pass a block. + +## Pagination (`pageable_get` / `pget`) + +```ruby +class MyPaginationApiClient < MyApiClient::Base + endpoint 'https://example.com/v1' + + def users + pageable_get 'users', paging: '$.links.next', query: { page: 1 } + end +end + +api_client = MyPaginationApiClient.new +api_client.users.take(3).each { |page| p page } +``` + +`paging` can be: + +- JSONPath string (for response body) +- `Proc` (custom next-page extraction) diff --git a/docs/en/03-error-handling.md b/docs/en/03-error-handling.md new file mode 100644 index 00000000..2b5a90c6 --- /dev/null +++ b/docs/en/03-error-handling.md @@ -0,0 +1,57 @@ +# Error Handling + +## Basic Rules + +```ruby +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 +end +``` + +Matchers support: + +- `status_code`: `Integer`, `Range`, `Regexp`, `Symbol` +- `headers`: `Hash` values with `String` or `Regexp` +- `json`: `Hash` values with `String`, `Integer`, `Range`, `Regexp`, `Symbol` + +## Custom Processing + +Use a block: + +```ruby +error_handling status_code: 500..599, raise: MyApiClient::ServerError do |params, logger| + logger.warn "Response Body: #{params.response&.body.inspect}" +end +``` + +Or use `with:` to call an instance method: + +```ruby +error_handling json: { '$.errors.code': 10..19 }, with: :log_error +``` + +## `forbid_nil` + +```ruby +error_handling status_code: 200, json: :forbid_nil +``` + +Useful when an empty response body should be treated as an error. + +## Error Object (`MyApiClient::Error`) + +All gem-specific errors inherit `MyApiClient::Error` and expose `#params`. + +```ruby +begin + api_client.get_users(condition: 'john') +rescue MyApiClient::Error => e + e.params.request + e.params.response +end +``` + +`e.params.metadata` (or `e.metadata`) is useful for external logging tools. diff --git a/docs/en/04-retry-timeout-logger.md b/docs/en/04-retry-timeout-logger.md new file mode 100644 index 00000000..4831a9f9 --- /dev/null +++ b/docs/en/04-retry-timeout-logger.md @@ -0,0 +1,58 @@ +# Retry, Timeout, Logger + +## Retry + +```ruby +class ExampleApiClient < MyApiClient::Base + endpoint 'https://example.com' + + error_handling json: { '$.errors.code': 20 }, + raise: MyApiClient::ApiLimitError, + retry: { wait: 30.seconds, attempts: 3 } +end +``` + +Notes: + +- `retry: true` uses default retry options. +- `retry` requires `raise`. +- `retry` cannot be combined with block-style `error_handling`. + +`MyApiClient::NetworkError` is retried by default. + +## Network Error + +`MyApiClient::NetworkError` wraps low-level errors and exposes `#original_error`. + +```ruby +begin + api_client.get_users(condition: 'john') +rescue MyApiClient::NetworkError => e + e.original_error + e.params.response # nil +end +``` + +## Timeout + +```ruby +class ExampleApiClient < MyApiClient::Base + endpoint 'https://example.com' + http_read_timeout 10 + http_open_timeout 5 +end +``` + +## Logger + +```ruby +class ExampleApiClient < MyApiClient::Base + self.logger = Rails.logger +end +``` + +Request logs are emitted like: + +- `Start` +- `Duration ... msec` +- `Success (...)` or `Failure (...)` diff --git a/docs/en/05-rspec.md b/docs/en/05-rspec.md new file mode 100644 index 00000000..ec975147 --- /dev/null +++ b/docs/en/05-rspec.md @@ -0,0 +1,50 @@ +# RSpec Helpers and Matchers + +## Setup + +Add to `spec/spec_helper.rb` or `spec/rails_helper.rb`: + +```ruby +require 'my_api_client/rspec' +``` + +## Request Matcher (`request_to`) + +```ruby +expect { api_client.get_users(condition: 'john') } + .to request_to(:get, 'https://example.com/v1/users') + .with(query: { search: 'john' }) +``` + +## Error Matcher (`be_handled_as_an_error`) + +```ruby +expect { api_client.get_users(condition: 'john') } + .to be_handled_as_an_error(MyApiClient::ClientError) + .when_receive(status_code: 200, body: { errors: { code: 10 } }.to_json) +``` + +Retry assertion: + +```ruby +expect { api_client.get_users(condition: 'john') } + .to be_handled_as_an_error(MyApiClient::ApiLimitError) + .after_retry(3).times + .when_receive(status_code: 200, body: { errors: { code: 20 } }.to_json) +``` + +## Stubbing (`stub_api_client`, `stub_api_client_all`) + +```ruby +stub_api_client_all(ExampleApiClient, request: { response: { id: 1 } }) +ExampleApiClient.new.request(user_id: 10).id # => 1 +``` + +Supported options per action: + +- plain hash response +- `response:` +- `raise:` (error class or instance) +- `status_code:` (with `raise:`) +- `pageable:` (Enumerable for pagination) +- `Proc` for dynamic response diff --git a/docs/en/06-development.md b/docs/en/06-development.md new file mode 100644 index 00000000..777b2d20 --- /dev/null +++ b/docs/en/06-development.md @@ -0,0 +1,23 @@ +# Development and Release + +## Local Development + +```sh +bin/setup +rake spec +bin/console +``` + +## Install Gem Locally + +```sh +bundle exec rake install +``` + +## Release + +```sh +bundle exec rake release +``` + +This creates a tag, pushes commits/tags, and publishes to RubyGems. diff --git a/docs/ja/01-getting-started.md b/docs/ja/01-getting-started.md new file mode 100644 index 00000000..9b0be802 --- /dev/null +++ b/docs/ja/01-getting-started.md @@ -0,0 +1,29 @@ +# はじめに + +## インストール + +```ruby +gem 'my_api_client' +``` + +## Rails Generator + +```sh +rails g api_client path/to/resource get:path/to/resource --endpoint https://example.com +``` + +以下のようなファイルが生成されます。 + +- `app/api_clients/application_api_client.rb` +- `app/api_clients/path/to/resource_api_client.rb` +- `spec/api_clients/path/to/resource_api_client_spec.rb` + +## 最小構成 + +```ruby +class ApplicationApiClient < MyApiClient::Base + endpoint 'https://example.com/v1' +end +``` + +共通の `endpoint`、`logger`、エラーハンドリングは親クラスで定義すると運用しやすいです。 diff --git a/docs/ja/02-usage.md b/docs/ja/02-usage.md new file mode 100644 index 00000000..33a0b131 --- /dev/null +++ b/docs/ja/02-usage.md @@ -0,0 +1,47 @@ +# 基本的な使い方とページネーション + +## 基本リクエスト DSL + +```ruby +class ExampleApiClient < MyApiClient::Base + endpoint 'https://example.com/v1' + + def get_users(condition:) + get 'users', query: { search: condition } + end + + def post_user(name:) + post 'users', body: { name: name } + end +end +``` + +利用できるメソッド: + +- `get` +- `post` +- `put` +- `patch` +- `delete` + +ブロックを渡さない場合、戻り値は `response.data` (`Sawyer::Resource`) です。 + +## ページネーション (`pageable_get` / `pget`) + +```ruby +class MyPaginationApiClient < MyApiClient::Base + endpoint 'https://example.com/v1' + + def users + pageable_get 'users', paging: '$.links.next', query: { page: 1 } + end +end + +api_client = MyPaginationApiClient.new +api_client.users.take(3).each { |page| p page } +``` + +`paging` には次を指定できます。 + +- JSONPath 文字列 +- `Proc`(次ページ URL の独自抽出) diff --git a/docs/ja/03-error-handling.md b/docs/ja/03-error-handling.md new file mode 100644 index 00000000..3b07a901 --- /dev/null +++ b/docs/ja/03-error-handling.md @@ -0,0 +1,57 @@ +# エラーハンドリング + +## 基本定義 + +```ruby +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 +end +``` + +主な matcher: + +- `status_code`: `Integer`, `Range`, `Regexp`, `Symbol` +- `headers`: `Hash` の値に `String` または `Regexp` +- `json`: `Hash` の値に `String`, `Integer`, `Range`, `Regexp`, `Symbol` + +## 追加処理 + +ブロックを使う例: + +```ruby +error_handling status_code: 500..599, raise: MyApiClient::ServerError do |params, logger| + logger.warn "Response Body: #{params.response&.body.inspect}" +end +``` + +`with:` でインスタンスメソッド呼び出しも可能: + +```ruby +error_handling json: { '$.errors.code': 10..19 }, with: :log_error +``` + +## `forbid_nil` + +```ruby +error_handling status_code: 200, json: :forbid_nil +``` + +本来ボディが返る想定の API で、空レスポンスをエラー扱いしたい場合に有効です。 + +## 例外オブジェクト (`MyApiClient::Error`) + +この gem の例外は `MyApiClient::Error` を継承し、`#params` を持ちます。 + +```ruby +begin + api_client.get_users(condition: 'john') +rescue MyApiClient::Error => e + e.params.request + e.params.response +end +``` + +`e.params.metadata`(または `e.metadata`)は外部ロギング連携に使えます。 diff --git a/docs/ja/04-retry-timeout-logger.md b/docs/ja/04-retry-timeout-logger.md new file mode 100644 index 00000000..027dd904 --- /dev/null +++ b/docs/ja/04-retry-timeout-logger.md @@ -0,0 +1,58 @@ +# リトライ・タイムアウト・ロガー + +## リトライ + +```ruby +class ExampleApiClient < MyApiClient::Base + endpoint 'https://example.com' + + error_handling json: { '$.errors.code': 20 }, + raise: MyApiClient::ApiLimitError, + retry: { wait: 30.seconds, attempts: 3 } +end +``` + +補足: + +- `retry: true` で既定のリトライ設定を使えます +- `retry` を使う場合は `raise` が必須です +- ブロック付き `error_handling` と `retry` の併用はできません + +`MyApiClient::NetworkError` には標準で `retry_on` が定義されています。 + +## ネットワークエラー + +`MyApiClient::NetworkError` は低レイヤの例外をラップし、`#original_error` を参照できます。 + +```ruby +begin + api_client.get_users(condition: 'john') +rescue MyApiClient::NetworkError => e + e.original_error + e.params.response # nil +end +``` + +## タイムアウト + +```ruby +class ExampleApiClient < MyApiClient::Base + endpoint 'https://example.com' + http_read_timeout 10 + http_open_timeout 5 +end +``` + +## ロガー + +```ruby +class ExampleApiClient < MyApiClient::Base + self.logger = Rails.logger +end +``` + +リクエストごとに次のようなログが出力されます。 + +- `Start` +- `Duration ... msec` +- `Success (...)` または `Failure (...)` diff --git a/docs/ja/05-rspec.md b/docs/ja/05-rspec.md new file mode 100644 index 00000000..4bc950f9 --- /dev/null +++ b/docs/ja/05-rspec.md @@ -0,0 +1,50 @@ +# RSpec ヘルパーとマッチャ + +## セットアップ + +`spec/spec_helper.rb` または `spec/rails_helper.rb` に追記: + +```ruby +require 'my_api_client/rspec' +``` + +## リクエスト検証 (`request_to`) + +```ruby +expect { api_client.get_users(condition: 'john') } + .to request_to(:get, 'https://example.com/v1/users') + .with(query: { search: 'john' }) +``` + +## エラーハンドリング検証 (`be_handled_as_an_error`) + +```ruby +expect { api_client.get_users(condition: 'john') } + .to be_handled_as_an_error(MyApiClient::ClientError) + .when_receive(status_code: 200, body: { errors: { code: 10 } }.to_json) +``` + +リトライ回数の検証: + +```ruby +expect { api_client.get_users(condition: 'john') } + .to be_handled_as_an_error(MyApiClient::ApiLimitError) + .after_retry(3).times + .when_receive(status_code: 200, body: { errors: { code: 20 } }.to_json) +``` + +## スタブ (`stub_api_client`, `stub_api_client_all`) + +```ruby +stub_api_client_all(ExampleApiClient, request: { response: { id: 1 } }) +ExampleApiClient.new.request(user_id: 10).id # => 1 +``` + +アクションごとに指定できる主なオプション: + +- 通常のレスポンス Hash +- `response:` +- `raise:`(例外クラスまたは例外インスタンス) +- `status_code:`(`raise:` と併用) +- `pageable:`(ページネーション向け Enumerable) +- `Proc`(入力値に応じた動的レスポンス) diff --git a/docs/ja/06-development.md b/docs/ja/06-development.md new file mode 100644 index 00000000..64384b59 --- /dev/null +++ b/docs/ja/06-development.md @@ -0,0 +1,23 @@ +# 開発とリリース + +## ローカル開発 + +```sh +bin/setup +rake spec +bin/console +``` + +## ローカルインストール + +```sh +bundle exec rake install +``` + +## リリース + +```sh +bundle exec rake release +``` + +タグ作成、push、RubyGems への公開までを実行します。