This gem is an API client builder that provides generic functionality for defining API request classes. Its architecture is based on Sawyer and Faraday, with enhanced error-handling features.
Sawyer can be difficult for generating dummy data and may conflict with other gems in some cases, so this project may reduce direct Sawyer dependency in the future.
It is primarily designed for Ruby on Rails, but it also works in other environments. If you find any issues, please report them on the Issues page.
[toc]
- Ruby 3.2, 3.3, 3.4, 4.0
- Rails 7.2, 8.0, 8.1
Add this line to your application's Gemfile:
gem 'my_api_client'If you are using Ruby on Rails, you can use the 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.rbThe simplest usage example is shown below:
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 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_client = ExampleApiClient.new(access_token: 'access_token')
api_client.get_users #=> #<Sawyer::Resource>endpoint defines the base URL for requests. Each method then adds a relative path. In the example above, get 'users' sends GET https://example.com/v1/users.
Next, define #initialize to set values such as an access token or API key. You can omit it if you do not need any instance state.
Then define methods such as #get_users and #post_user. Inside those methods, call HTTP helpers like #get and #post. You can also use #patch, #put, and #delete.
Some APIs include a URL for the next page in the response.
MyApiClient provides #pageable_get to treat such APIs as an enumerable. An example is shown below:
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
endIn the example above, the first request is GET https://example.com/v1/pagination?page=1. It then continues requesting the URL in $.links.next from each response.
For example, in the response below, $.links.next points to "https://example.com/pagination?page=3":
{
"links": {
"next": "https://example.com/pagination?page=3",
"previous": "https://example.com/pagination?page=1"
},
"page": 2
}#pageable_get returns Enumerator::Lazy, so you can iterate using #each or #next:
api_client = MyPaginationApiClient.new
api_client.pagination.each do |response|
# Do something.
end
result = api_client.pagination
result.next # => 1st page result
result.next # => 2nd page result
result.next # => 3rd page resultNote 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:
# GET pagination?page=1
def pagination
pget 'pagination', paging: '$.links.next', headers: headers, query: { page: 1 }
endMyApiClient lets you define error handling rules that raise exceptions based on response content. For example:
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 request 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
endLet's go through each option. First, this rule checks status_code:
error_handling status_code: 400..499, raise: MyApiClient::ClientErrorThis raises MyApiClient::ClientError when the response status code is in 400..499 for requests from ExampleApiClient. Error handling rules are also inherited by child classes.
You can specify Integer, Range, or Regexp for status_code.
A class inheriting from MyApiClient::Error can be specified for raise. See here for built-in error classes. If raise is omitted, MyApiClient::Error is raised.
Next, here is an example using a block:
error_handling status_code: 500..599, raise: MyApiClient::ServerError do |_params, logger|
logger.warn 'Server error occurred.'
endIn this example, when the status code is 500..599, the block runs before MyApiClient::ServerError is raised. The params argument includes both request and response information.
logger is a request-scoped logger. If you log with this instance, request information is automatically included, which is useful for debugging:
API request `GET https://example.com/path/to/resource`: "Server error occurred."
error_handling json: { '$.errors.code': 10..19 }, with: :my_error_handlingFor json, use JSONPath as the hash key, extract values from response JSON, and match them against expected values. You can specify String, Integer, Range, or Regexp as matcher values.
In this case, it matches JSON such as:
{
"errors": {
"code": 10,
"message": "Some error has occurred."
}
}For headers, specify a response-header key and match its value. You can specify String or Regexp as matcher values.
error_handling headers: { 'www-authenticate': /invalid token/ }, with: :my_error_handlingIn this case, it matches response headers such as:
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 an instance method name in with, you can run arbitrary logic before raising an exception. The method receives params and logger, just like a block. Note that block and with cannot be used together.
# @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}"
endBy default, MyApiClient treats 4xx and 5xx responses as exceptions. In the 4xx range, it raises an exception class inheriting from MyApiClient::ClientError; in the 5xx range, it raises one inheriting from MyApiClient::ServerError.
Also, retry_on is defined by default for MyApiClient::NetworkError.
Both can be overridden, so define error_handling as needed.
They are defined here.
error_handling json: { '$.errors.code': :negative? }This is an experimental feature. By specifying a Symbol as the value for status or json, MyApiClient calls that method on the extracted value and uses the result for matching. In the example above, it matches the following JSON. If #negative? does not exist on the target object, the method is not called.
error_handling status_code: 200, json: :forbid_nilSome services expect a non-empty response body but occasionally receive an empty one. This experimental option, json: :forbid_nil, helps detect that case. Normally, an empty response body is not treated as an error, but with this option it is. Be careful of false positives, because some APIs intentionally return empty responses.
MyApiClient::Params::Params is a value object that combines request and response details.
An instance of this class is passed to error handlers (block/with) and is also available from MyApiClient::Error#params.
#request:MyApiClient::Params::Request(method, URL, headers, and body)#response:Sawyer::Response(ornilfor network errors)
It also provides #metadata (#to_bugsnag alias), which merges request/response data into a single hash for logging and error reporting.
begin
api_client.request
rescue MyApiClient::Error => e
e.params.metadata
# => {
# request_line: "GET https://example.com/v1/users?search=foo",
# request_headers: { "Authorization" => "Bearer token" },
# response_status: 429,
# response_headers: { "content-type" => "application/json" },
# response_body: { errors: [{ code: 20 }] },
# duration: 0.123
# }
endIf an API response matches a rule defined in error_handling, the exception class specified in raise is triggered. This exception class must inherit from MyApiClient::Error.
This exception class has a method called #params, which allows you to refer to request and response parameters.
begin
api_client.request
rescue MyApiClient::Error => e
e.params.inspect
# => {
# :request=>"#<MyApiClient::Params::Request#inspect>",
# :response=>"#<Sawyer::Response#inspect>",
# }
endIf you are using Bugsnag-Ruby v6.11.0 or later, the breadcrumbs feature is supported automatically. When MyApiClient::Error occurs, Bugsnag.leave_breadcrumb is called internally, so you can inspect request and response details in the Bugsnag console.
Next, let's look at retry support in 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
endWhen an API request is executed many times, network errors can occur. Sometimes the network is unavailable for a long time, but often the error is temporary. In MyApiClient, network-related exceptions are wrapped as MyApiClient::NetworkError. Using retry_on, you can handle such exceptions and retry requests with configurable wait time and attempt count, similar to ActiveJob.
retry_on MyApiClient::NetworkError is enabled by default, so you do not need to define it unless you want custom wait or attempts values.
Unlike ActiveJob, retries are performed synchronously. In practice, this is most useful for short-lived network interruptions. You can also retry for API rate limits as in the example above, but handling that with ActiveJob may be a better fit depending on your workload.
discard_on is also implemented, but details are omitted here because a strong use case has not been identified yet.
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:
retry_on MyApiClient::ApiLimitError, wait: 30.seconds, attempts: 3
error_handling json: { '$.errors.code': 20 },
raise: MyApiClient::ApiLimitErrorerror_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, you can use retry: true:
error_handling json: { '$.errors.code': 20 },
raise: MyApiClient::ApiLimitError,
retry: trueKeep the following in mind when using the retry option:
- The
raiseoption must be specified forerror_handling - Definition of
error_handlingusingblockis prohibited
As mentioned above, MyApiClient wraps network exceptions as MyApiClient::NetworkError. Like other client errors, its parent class is MyApiClient::Error. The list of wrapped exception classes is available in MyApiClient::NETWORK_ERRORS. You can inspect the original exception via #original_error:
begin
api_client.request
rescue MyApiClient::NetworkError => e
e.original_error # => #<Net::OpenTimeout>
e.params.response # => nil
endUnlike normal API errors that are raised after receiving a response, this exception is raised during request execution. Therefore, the exception instance does not include response parameters.
You can configure HTTP timeout values per API client class:
class ApplicationApiClient < MyApiClient::Base
http_open_timeout 2.seconds
http_read_timeout 3.seconds
endhttp_open_timeout: maximum wait time to open a connectionhttp_read_timeout: maximum wait time for each HTTP read
Internally, these are passed to Faraday request options as open_timeout and timeout.
If a timeout occurs, it is wrapped and raised as MyApiClient::NetworkError.
Each API client class has a configurable logger (self.logger).
By default, MyApiClient uses Logger.new($stdout), and in Rails apps you typically set:
class ApplicationApiClient < MyApiClient::Base
self.logger = Rails.logger
endMyApiClient wraps this logger with MyApiClient::Request::Logger and prefixes messages with request information:
API request `GET https://example.com/v1/users`: "Start"
API request `GET https://example.com/v1/users`: "Duration 100.0 msec"
API request `GET https://example.com/v1/users`: "Success (200)"
On failure, it logs:
API request `GET https://example.com/v1/users`: "Failure (Net::OpenTimeout)"
In many cases, APIs on the same host share request headers and error structures, so defining multiple endpoints in one class is practical. If you prefer API-level separation, you can also use a "one class per API" design:
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 to create
# @return [Sawyer::Resource] HTTP resource parameter
def request(name:)
post 'users', headers: headers, body: { name: name }
end
endSupports testing with RSpec.
Add the following code to spec/spec_helper.rb (or spec/rails_helper.rb):
require 'my_api_client/rspec'Suppose you have defined an ApiClient like this:
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
endWhen you define a new API client, these are the two main test targets:
- It sends the expected HTTP request (method, URL, headers/query/body)
- It handles error responses as expected (
error_handling)
MyApiClient provides custom matchers for both.
Use request_to to assert method/URL and with to assert headers, query, or body.
expect must receive a block.
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: { search: 'condition' })
end
end
endUse be_handled_as_an_error to assert the raised error class, and when_receive to provide mock response input (status_code, headers, body).
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)
endYou can also assert that a response is not handled as an error:
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)
endIf the client has retry_on, you can assert retry count with after_retry(...).times:
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)
endUse stub_api_client_all or stub_api_client to stub API client methods without real HTTP.
class ExampleApiClient < MyApiClient::Base
endpoint 'https://example.com'
def request(user_id:)
get "users/#{user_id}"
end
end
stub_api_client_all(
ExampleApiClient,
request: { response: { id: 12_345 } }
)
response = ExampleApiClient.new.request(user_id: 1)
response.id # => 12345response can be omitted as shorthand:
stub_api_client_all(
ExampleApiClient,
request: { id: 12_345 }
)You can generate response data from request arguments:
stub_api_client_all(
ExampleApiClient,
request: ->(params) { { id: params[:user_id] } }
)Both methods return a spy object, so you can assert received calls:
def execute_api_request
ExampleApiClient.new.request(user_id: 1)
end
api_client = stub_api_client_all(ExampleApiClient, request: nil)
execute_api_request
expect(api_client).to have_received(:request).with(user_id: 1)To test error paths, use the raise option:
stub_api_client_all(ExampleApiClient, request: { raise: MyApiClient::Error })
expect { ExampleApiClient.new.request(user_id: 1) }.to raise_error(MyApiClient::Error)You can combine raise, response, and status_code:
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
e.params.response.data.to_h # => { message: "error" }
e.params.response.status # => 429
endFor #pageable_get (#pget), you can stub page-by-page responses:
stub_api_client_all(
MyPaginationApiClient,
pagination: {
pageable: [
{ page: 1 },
{ page: 2 },
{ page: 3 },
],
}
)
MyPaginationApiClient.new.pagination.each do |response|
response.page #=> 1, 2, 3
endEach page entry supports the same options (response, raise, Proc, etc.).
You can also pass an Enumerator for endless pagination stubs.
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.
The integration specs under spec/integrations/api_clients/ call the local my_api Rails server via HTTP.
Run with Docker Compose:
docker compose up -d --build my_api
docker compose run --rm test bundle exec rspec
docker compose down --volumes --remove-orphansRun only integration specs:
docker compose up -d --build my_api
docker compose run --rm test bundle exec rspec spec/integrations/api_clients
docker compose down --volumes --remove-orphansTo install this gem onto your local machine, run bundle exec rake install.
This project uses gem_comet for release automation.
Create .envrc and set GITHUB_ACCESS_TOKEN:
cp .envrc.skeleton .envrcInstall gem_comet:
gem install gem_cometCheck PRs merged since the previous release:
gem_comet changelogStart a release with a new version:
gem_comet release {VERSION}This creates two PRs:
Update v{VERSION}Release v{VERSION}
Merge Update v{VERSION} first after checking version bump and polishing CHANGELOG.md.
Then verify Release v{VERSION} (including CI) and merge it to publish the gem.
Bug reports and pull requests are welcome on GitHub at https://github.com/ryz310/my_api_client. Reports in Japanese are also welcome. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
The gem is available as open source under the terms of the MIT License.
Everyone interacting in the MyApiClient project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.