Client API Builder
A Ruby gem for building robust, secure API clients through declarative configuration. Define your API endpoints and their behavior with minimal boilerplate while benefiting from built-in security features, automatic retries, and comprehensive error handling.
Features
- Declarative Configuration - Define API endpoints with a clean DSL
- Security by Default - SSL/TLS verification, path traversal protection, SSRF prevention
- Automatic HTTP Method Detection - Intelligently determines HTTP methods from route names
- Flexible Request Building - Support for JSON, query params, and custom body builders
- Nested Routing - Organize complex APIs with hierarchical route structures
- Retry Logic - Configurable automatic retries for transient network failures
- Transports - Choose how requests reach the network per environment at boot, so one client class can open a connection per request in a web server and keep persistent connections in a worker
- Connection Pooling - Opt-in persistent connections shared across threads, with no extra gems
- Per-Thread Connections - Opt-in persistent connections dedicated to each thread, with no pool to size
-
HTTP/2 - Opt-in HTTP/2 over TLS, multiplexing concurrent requests on one connection per host (with the
http-2gem) - Streaming Support - Handle large payloads efficiently with streaming to files or IO
- ActiveSupport Integration - Optional logging and instrumentation
- Comprehensive Error Handling - Detailed error information for debugging
Installation
Add this line to your application's Gemfile:
gem 'client-api-builder'And then execute:
$ bundle installOr install it yourself:
$ gem install client-api-builderQuick Start
class GitHubClient
include ClientApiBuilder::Router
base_url 'https://api.github.com'
header 'Accept', 'application/vnd.github.v3+json'
header 'User-Agent', 'MyApp/1.0'
# Authentication header from instance method
header 'Authorization' do
"Bearer #{access_token}"
end
attr_accessor :access_token
# GET /users/:username
route :get_user, '/users/:username'
# GET /users/:username/repos
route :get_repos, '/users/:username/repos', query: { per_page: :per_page }
# POST /user/repos
route :create_repo, '/user/repos', body: { name: :name, private: :private }
end
client = GitHubClient.new
client.access_token = 'ghp_xxxxxxxxxxxx'
# Fetch a user
user = client.get_user(username: 'octocat')
# List repositories with pagination
repos = client.get_repos(username: 'octocat', per_page: 10)
# Create a new repository
new_repo = client.create_repo(name: 'my-new-repo', private: true)Usage Guide
Defining Routes
Routes are defined using the route class method:
route :method_name, '/path/:param', optionsOptions:
| Option | Description |
|---|---|
method: |
HTTP method. Auto-detected from the route name if omitted. Any of :get, :post, :put, :patch, :delete, :head, :options, :trace, :copy, :lock, :unlock, :mkcol, :move, :propfind, :proppatch. |
query: |
Hash defining query parameters. Use symbols for dynamic values. |
body: |
Request body: a Hash or Array (symbols become arguments) or a literal String. |
no_body: |
true to send no body, even for POST/PUT/PATCH. |
has_body: |
true to add a body: argument to any method, e.g. a GET with a body. |
expected_response_code: |
Single expected HTTP status code. Without one, any 2xx response is accepted. |
expected_response_codes: |
Array of expected HTTP status codes |
stream: |
Enable streaming (:file, :io, :block, or true) |
return: |
Return type (:response, :body, or parsed JSON by default) |
POST, PUT and PATCH routes without a body: option take the request body as a body: argument:
route :create_user, '/users'
client.create_user(body: { name: 'Ann' })Per-Request Options
Every generated method also accepts options that apply to that call only:
client.get_user(
id: 1,
headers: { 'X-Trace-Id' => 'abc' }, # merged over the class headers; nil removes one
query: { expand: 'teams' }, # merged over the route's query params
body: { name: 'Ann' }, # replaces the route's body
connection_options: { read_timeout: 5 },
retries: 3, # attempts for this call
sleep: 0.5, # seconds between attempts
backoff: 2, # multiply the sleep by this after each retry
max_sleep: 5, # cap on the sleep between attempts
jitter: true, # randomly shorten each sleep (true, false or 0..1)
return: :body # :body or :response instead of parsed JSON
)Automatic HTTP Method Detection
The Router detects the HTTP method from how the route name starts:
| Name starts with the word | HTTP Method |
|---|---|
post, create, add, insert
|
POST |
put, update, modify, change
|
PUT |
patch |
PATCH |
delete, remove, destroy
|
DELETE |
| anything else | GET |
The verb must be the whole name or be followed by _: create_user and delete are detected, while names such as posts, addresses, deleted_users or updates_feed are GET. Pass method: when the name doesn't say it.
class MyApiClient
include ClientApiBuilder::Router
base_url 'https://api.example.com'
# Automatically uses appropriate HTTP methods
route :get_users, '/users' # GET
route :create_user, '/users', body: { name: :name } # POST
route :update_user, '/users/:id', body: { name: :name } # PUT
route :patch_user, '/users/:id', body: { name: :name } # PATCH
route :delete_user, '/users/:id' # DELETE
endDynamic Parameters
Parameters can be defined in three ways:
1. Path Parameters (using :param syntax):
route :get_user, '/users/:id'
# client.get_user(id: 1)A colon directly after a letter, digit, _ or } is literal text, so paths like /v1/items:batchGet, /v1/{name}:cancel or /slots/12:30 need no escaping. Parameter names start with a letter or _.
{name} is filled from the client's own name method rather than an argument, which suits values like account IDs that are set once. If the route also has an argument called name, the argument is used instead (in sections, path values always come from the root client's method):
attr_accessor :account_id
route :get_invoices, '/accounts/{account_id}/invoices'
# client.get_invoicesThe same {name} form works inside query: and body: strings. A string that is exactly '{name}' passes the value through unchanged, so numbers, arrays and hashes keep their type. Placeholders within text are filled in as strings, and every one is used:
route :search_issues, '/issues', query: { q: 'author:{username} state:{state}' }
route :create_report, '/reports', body: { title: 'Report for {username}', limit: '{page_size}' }
# q=author:octocat state:open {"title":"Report for octocat","limit":50}query: and body: are compiled into the generated method when the class loads, so their fixed values must be strings, numbers, booleans, nil, hashes or arrays. Other objects (Time, Date, BigDecimal, Range, ...) raise an ArgumentError naming the route; pass them as an argument or a '{method}' placeholder, or write them as a string.
Any {identifier} in these strings is a placeholder; other text, including quotes and #{...}, is sent literally. Top-level String bodies (body: '...') are always sent as is.
Path values, from arguments and {name} alike, are percent-encoded so each stays a single segment: get_file(name: 'a/b c') requests /files/a%2Fb%20c. Only RFC 3986 unreserved characters (A-Z a-z 0-9 - . _ ~) are left as is. To change this, override escape_path:
# Allow '/' in values, e.g. for nested object keys
def escape_path(value)
value.to_s.split('/').map { |part| ERB::Util.url_encode(part) }.join('/')
end2. Query Parameters:
route :search_users, '/users', query: { q: :query, page: :page, limit: :limit }
# Generates: GET /users?q=...&page=...&limit=...3. Body Parameters:
route :create_user, '/users', body: { user: { name: :name, email: :email } }
# Sends JSON: {"user": {"name": "...", "email": "..."}}Headers
Define headers at the class level or dynamically:
class MyApiClient
include ClientApiBuilder::Router
base_url 'https://api.example.com'
# Static header
header 'Content-Type', 'application/json'
# Dynamic header from instance method
header 'Authorization', :auth_header
# Dynamic header from block
header 'X-Request-ID' do
SecureRandom.uuid
end
attr_accessor :api_key
def auth_header
"Bearer #{api_key}"
end
endA symbol or block given to header or query_param is evaluated on the client for every request. Values passed when calling a route, including per-request headers: and query:, are always sent as given, so client.list_items(sort: :asc) sends sort=asc. Header values are sent as strings; setting one to nil for a request leaves it out.
Request Body Formats
Configure how request bodies are serialized:
class MyApiClient
include ClientApiBuilder::Router
# Default: JSON (using to_json)
body_builder :to_json
# URL-encoded form data (using to_query, requires ActiveSupport)
body_builder :to_query
# Custom query params builder (no ActiveSupport dependency)
body_builder :query_params
# Custom builder method
body_builder :my_custom_builder
# Custom builder with block
body_builder do |data|
data.to_xml
end
def my_custom_builder(data)
# Custom serialization logic
end
endString bodies are sent as is. Query strings are built the same way with query_builder, which accepts :to_query, :query_params, a method name or a block. It defaults to :to_query when ActiveSupport is loaded and to :query_params otherwise.
Nested Routing (Sections)
Organize complex APIs with nested routes:
class MyApiClient
include ClientApiBuilder::Router
base_url 'https://api.example.com'
header 'Authorization', :authorization
attr_accessor :auth_token
def authorization
"Bearer #{auth_token}"
end
# Use the root client's headers (Authorization) beneath the section's own
section :users, inherit: :headers do
base_url 'https://api.example.com/v2' # Override base URL
route :list, '/users'
route :get, '/users/:id'
route :create, '/users', body: { name: :name, email: :email }
end
section :posts do
header 'Authorization', :authorization # or declare what it needs itself
route :list, '/posts'
route :get, '/posts/:id'
end
end
client = MyApiClient.new
client.auth_token = 'secret'
# Access nested routes
users = client.users.list
user = client.users.get(id: 123)
posts = client.posts.listA section is its own router class. It uses the parent's base_url unless it sets one, but by default nothing else is inherited: declare the headers, query params and connection options it needs inside the section, or opt into the root client's with inherit::
section :users, inherit: %i[headers query_params connection_options] do
# or, inside the block: inherit_from_root :headers
endInherited settings are the root client's class-level ones, read on every request (so ones declared after the section, or in a subclass of the client, apply too). The section's own settings override them, and per-request options override both; a per-request nil header still drops an inherited one. Retries and body/query builders are always the section's own. Any other options passed to section are available to it as nested_router_options. Get a section's class with <name>_router (MyApiClient.users_router) rather than its constant name; see UPGRADING.md.
Symbol and block values given to header and query_param, {name} path values, and response blocks are evaluated on the root client, so they can use its methods and state.
Connection Options
Configure connection settings:
class MyApiClient
include ClientApiBuilder::Router
base_url 'https://api.example.com'
# Set timeouts
connection_option :open_timeout, 10
connection_option :read_timeout, 30
# SSL options (verify_mode is enabled by default)
connection_option :ssl_timeout, 10
endAny Net::HTTP.start option can be set this way. Your options are applied over the secure HTTPS defaults, so setting verify_mode yourself replaces VERIFY_PEER.
Transports
How requests reach the network is up to a transport, chosen for the environment at boot rather than in the client class. The same GitHubClient can open a connection per request inside a web server and keep per-thread connections inside a background worker:
# config/puma.rb, or any web server's boot code: the default, shown for clarity
ClientApiBuilder.use_transport(ClientApiBuilder::Transports::OneShot.new)
# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
ClientApiBuilder.use_transport(ClientApiBuilder::Transports::ThreadLocal.new(ttl: 30))
config.on(:shutdown) { ClientApiBuilder.close_transports }
end| Transport | Connections | Suits |
|---|---|---|
Transports::OneShot.new (default) |
A new one per request, closed once the response is read | Web servers, forking process managers, low request rates |
Transports::Pooled.new(max_connections:, ttl:, checkout_timeout:, idle_timeout:) |
Pools shared by every thread, one per scheme, host, port and connection options | Many threads, including short-lived ones (details) |
Transports::ThreadLocal.new(ttl:, idle_timeout:) |
One per thread and host, reused by that thread's requests | A fixed set of worker threads (details) |
Transports::HTTP2.new(fallback: Transports::OneShot.new) |
One HTTP/2 connection per origin, carrying concurrent requests as streams | Many threads waiting on one slow https API (details) |
Register a transport for every client, for one client class (and its subclasses), or for one client instance:
ClientApiBuilder.use_transport(ClientApiBuilder::Transports::ThreadLocal.new) # every client
ClientApiBuilder.use_transport(ClientApiBuilder::Transports::Pooled.new(max_connections: 10), GitHubClient)
client.transport = ClientApiBuilder::Transports::OneShot.new # this instance only
ClientApiBuilder.close_transports # closes every registered transport's connections, e.g. at shutdownA request uses the first transport it finds:
- The one set on the client instance with
transport=. - The one registered with
use_transportfor the client's class or its nearest ancestor (a module works too, for every class including it). - The one the class itself includes (
ConnectionPools,ThreadConnectionsorHTTP2, below), or its nearest ancestor's. - For a section, its root client's transport.
- The process default set with
use_transport(transport), elseOneShot.
So a class can include a sensible default for its API, and the environment still decides: use_transport for the class wins whether it runs before or after the class is loaded. Sections use their root client's transport, so they share its connections, unless a transport is set or registered for the section, or it calls connection_pool or connection_per_thread.
Every transport is fork-safe (after a fork, the child opens its own connections and leaves the parent's alone) and closes a connection whose request raised rather than reusing it.
A transport is any object with with_connection(uri, connection_options) { |http| ... }, which yields something answering request(net_request) { |response| ... } like a started Net::HTTP, and close. connection_options are the Net::HTTP.start options, with the secure HTTPS defaults applied.
Persistent Connections
Transports::Pooled keeps connections open and reuses them:
# Optional settings; these are the defaults
ClientApiBuilder.use_transport(
ClientApiBuilder::Transports::Pooled.new(max_connections: 5, # connections per host
ttl: 30, # seconds before a connection is closed and replaced
checkout_timeout: 5, # seconds to wait for a free connection
idle_timeout: 2) # seconds idle before Net::HTTP reconnects (keep_alive_timeout)
)Or give a client class its own pools, as its default, by including ClientApiBuilder::ConnectionPools (after Router):
class MyApiClient
include ClientApiBuilder::Router
include ClientApiBuilder::ConnectionPools
base_url 'https://api.example.com'
connection_pool max_connections: 10, ttl: 60 # optional; takes the same settings
endEvery client using the transport shares its pools: keep one client instance per thread and the threads share the connections. A connection is checked out for one request (or one streamed response) and returned straight after, so max_connections can be lower than your thread count. There is one pool per scheme, host, port and connection options.
-
Sockets the server has closed, or that sat idle past
idle_timeout, are reopened automatically. -
A connection whose request raised is closed rather than reused.
-
When no connection frees up within
checkout_timeout, the request raisesClientApiBuilder::ConnectionPools::TimeoutError. -
With
ConnectionPools, sections use their root client's pools, getting a separate pool for any other host in theirbase_url. Subclasses share their parent's pools unless they callconnection_poolthemselves. -
A section can call
connection_poolin its block to get its own pools and settings. This works whatever transport the root client uses:section :uploads do base_url 'https://uploads.example.com' connection_pool max_connections: 2, ttl: 10 end
Sections nested inside it still fall back to the root client, as they do for
base_url. -
After a fork (Puma, Sidekiq, Resque), the child process opens its own connections.
-
transport.close(orMyApiClient.close_connections, withConnectionPools) closes idle connections now and in-use ones when they're returned.close_connectionsalso closes the pools of sections that have their own.
Pooling uses only Net::HTTP from the standard library.
Per-Thread Connections
When a program runs a fixed set of worker threads, each making its own requests, Transports::ThreadLocal gives every thread its own persistent connections:
# Optional settings; these are the defaults
ClientApiBuilder.use_transport(
ClientApiBuilder::Transports::ThreadLocal.new(ttl: 30, # seconds before a connection is closed and replaced
idle_timeout: 2) # seconds idle before Net::HTTP reconnects (keep_alive_timeout)
)
workers = Array.new(20) do
Thread.new do
client = MyApiClient.new
user_ids.each { |id| client.get_user(id: id) } # always over this thread's connection
end
endTo make it a client class's default, include ClientApiBuilder::ThreadConnections (after Router, instead of ConnectionPools) and optionally call connection_per_thread ttl: 30, idle_timeout: 2 in the class.
A thread opens a connection per host (scheme, port and connection options) on its first request and reuses it for every later one. There's no pool size to keep in step with the thread count, and no thread ever waits for another's connection.
- Sockets the server has closed, or that sat idle past
idle_timeout, are reopened automatically. A connection whose request raised is closed rather than reused. - A request made while the thread's connection is busy (from inside a streaming block, or from another fiber on the thread) opens a connection of its own; the thread keeps one of the two afterwards.
- Connections of threads that have finished are closed when a thread makes its first request. With short-lived threads, prefer
Transports::Pooled. - With
ThreadConnections, sections and subclasses share the class's connections the same way they share pools, and a section can callconnection_per_threadin its block to get its own. - After a fork, the child process opens its own connections.
transport.close(orMyApiClient.close_connections) closes every thread's idle connections now and in-use ones when their request ends.
HTTP/2
Transports::HTTP2 sends https requests over HTTP/2. It needs the http-2 gem, which isn't installed with this one:
# Gemfile
gem 'http-2'# fallback: is used for http:// URLs and servers that only speak HTTP/1.1 (default: OneShot)
ClientApiBuilder.use_transport(
ClientApiBuilder::Transports::HTTP2.new(fallback: ClientApiBuilder::Transports::Pooled.new),
MyApiClient
)Or make it a client class's default by including ClientApiBuilder::HTTP2 (after Router, and after ConnectionPools or ThreadConnections to use them as the fallback):
class MyApiClient
include ClientApiBuilder::Router
include ClientApiBuilder::ConnectionPools # optional: used for servers that only speak HTTP/1.1
include ClientApiBuilder::HTTP2
base_url 'https://api.example.com'
endEvery client using the transport shares one connection per host (and connection options), and concurrent requests from different threads travel over it as separate streams. Responses are ordinary Net::HTTPResponse objects with http_version '2.0', so handle_response, UnexpectedResponse#response and streaming routes work unchanged.
- The protocol is negotiated during the TLS handshake (ALPN). When a server picks HTTP/1.1, that host is remembered and its requests go through the fallback transport.
http://URLs always use the fallback. - The usual connection options apply:
open_timeout,read_timeout(per response header and body chunk), and the SSL options (verify_mode,ca_file,cert_store,cert,key, ...). Proxies aren't supported. - When the server's limit on concurrent streams is reached, a request waits up to
read_timeoutfor a stream to free up. - Gzip and deflate responses are inflated, as
Net::HTTPdoes, unless you setAccept-Encodingyourself. - A stream the server refused, or never processed before closing the connection (GOAWAY), raises
ClientApiBuilder::HTTP2::StreamRefused; a dropped connection raisesClientApiBuilder::HTTP2::ConnectionLost. Both are retried byconfigure_retries. A stream the server reset raisesClientApiBuilder::HTTP2::StreamError. - Response data is acknowledged to the server as it arrives, so a streaming consumer slower than the server buffers the difference in memory.
- After a fork, the child opens its own connections.
transport.close(orMyApiClient.close_connections, withinclude ClientApiBuilder::HTTP2) closes the HTTP/2 connections and the fallback's.
When it's faster. script/benchmark_http2.rb compares a new connection per request, ConnectionPools and HTTP/2 against a local TLS server. On one machine (Ruby 4.0.7, http-2 1.2.3):
| Scenario (1000 requests) | New connection | Pooled | HTTP/2 |
|---|---|---|---|
| 1 thread, no server delay | 501 req/s | 4,824 req/s | 2,057 req/s |
| 50 threads, 20ms delay, one pooled connection per thread | 628 req/s | 1,904 req/s | 1,765 req/s |
| 50 threads, 20ms delay, default pool of 5 | 640 req/s | 208 req/s | 1,706 req/s |
HTTP/2 avoids a TLS handshake per request, like the pools, and isn't limited by a connection count: many threads waiting on a slow API share one connection. Per request, it costs more client CPU than pooled HTTP/1.1 (the protocol layer is pure Ruby), which shows most with large bodies. If you can give each thread its own connection (a pool as large as the thread count, or Transports::ThreadLocal), HTTP/1.1 is usually as fast or faster.
Retry Configuration
Configure automatic retries for transient failures:
class MyApiClient
include ClientApiBuilder::Router
base_url 'https://api.example.com'
# Make up to 3 attempts in total, waiting 0.5 seconds between them
configure_retries 3, 0.5
endThe first argument is the total number of attempts, not extra retries. The default is 1, so requests are not retried unless you configure it.
For exponential backoff, use configure_exponential_retries:
class MyApiClient
include ClientApiBuilder::Router
# Up to 5 attempts, sleeping 0.1s, 0.2s, 0.4s, 0.8s between them (never more than 10s)
configure_exponential_retries attempts: 5, initial: 0.1, max: 10
endinitial defaults to 0.1, max to 10 and the multiplier to 2. It is shorthand for configure_retries 5, 0.1, backoff: 2, max_sleep: 10: the sleep before retry n is sleep * backoff**(n - 1), capped at max_sleep. configure_retries defaults to backoff: 1 (a fixed sleep) and no cap.
Add jitter: so many clients that fail together don't all retry at the same moment. jitter: true sleeps a random time between 0 and the computed sleep; a number between 0 and 1 shortens it by up to that fraction (jitter: 0.5 sleeps between 50% and 100% of it):
configure_exponential_retries attempts: 5, initial: 0.1, max: 10, jitter: trueretries:, sleep:, backoff:, max_sleep: and jitter: can also be passed per request.
Only these network errors are retried by default:
-
Net::OpenTimeout,Net::ReadTimeout -
Errno::ECONNRESET,Errno::ECONNREFUSED,Errno::ETIMEDOUT -
SocketError,EOFError
Customize retry behavior by overriding retry_request?:
class MyApiClient
include ClientApiBuilder::Router
def retry_request?(exception, options)
case exception
when Net::OpenTimeout, Net::ReadTimeout
true
when ClientApiBuilder::UnexpectedResponse
# Retry on 503 Service Unavailable
exception.response.code == '503'
else
false
end
end
endStreaming Support
Handle large responses efficiently:
class MyApiClient
include ClientApiBuilder::Router
base_url 'https://api.example.com'
# Stream directly to a file
route :download_file, '/files/:id/download', stream: :file
# Stream to an IO object
route :stream_to_io, '/files/:id/stream', stream: :io
# Stream with block processing
route :process_stream, '/events/stream', stream: :block
end
client = MyApiClient.new
# Download to file
client.download_file(id: 123, file: '/path/to/output.zip')
# Stream to IO
File.open('/path/to/output.dat', 'wb') do |file|
client.stream_to_io(id: 123, io: file)
end
# Process stream in chunks
client.process_stream do |response, chunk|
puts "Received #{chunk.bytesize} bytes"
process_data(chunk)
endStreaming routes return the Net::HTTPResponse. Files are written in wb mode by default; pass connection_options: { file_mode: 'ab' } to append.
The status is checked against the route's expected response codes before any of the body is streamed. An error response raises UnexpectedResponse with the error body in e.response.body, and nothing is written to the file, IO or block; an existing file is left untouched.
Response Handling
Customize how responses are processed:
class MyApiClient
include ClientApiBuilder::Router
base_url 'https://api.example.com'
# Return parsed JSON (default)
route :get_user, '/users/:id'
# Return raw response body
route :get_raw, '/raw/:id', return: :body
# Return Net::HTTPResponse object
route :get_response, '/data/:id', return: :response
# Custom response handling with block
route :get_token, '/auth/token' do |data|
self.auth_token = data['access_token']
data
end
end
# A block passed to the call replaces the route's block
client.get_user(id: 1) { |data| data['name'] }Blocks run on the client, so they can call its methods and set its state. Empty response bodies return nil.
Redefining a route, in the same class or a subclass, replaces the whole definition including its block; without a block it goes back to the default handling. Pass the block again to keep it.
Error Handling
ClientApiBuilder::UnexpectedResponse is raised when the status code isn't expected (any non-2xx by default, or anything outside expected_response_code(s)) and when a response body isn't valid JSON. It carries the response:
begin
client.get_user(id: 999)
rescue ClientApiBuilder::UnexpectedResponse => e
puts "HTTP Status: #{e.response.code}"
puts "Response Body: #{e.response.body}"
puts "Error Message: #{e.message}"
endDebugging
Access request and response details after each call:
client = MyApiClient.new
client.get_user(id: 123)
# Response information
puts client.response.code # HTTP status code
puts client.response.body # Response body
puts client.response.to_hash # Response headers
# Request information
puts client.request_options[:method] # HTTP method used
puts client.request_options[:uri] # Full URI
puts client.request_options[:body] # Request body
puts client.request_options[:headers] # Request headers
# Performance metrics
puts client.total_request_time # Time in seconds
puts client.request_attempts # Number of attempts (including retries)These describe the latest attempt. If it failed, response is nil when no response arrived, and request_options is nil when the request couldn't be built.
ActiveSupport Integration
When ActiveSupport is loaded before your client class includes ClientApiBuilder::Router, every request is instrumented as a client_api_builder.request event:
# Subscribe to request events
ActiveSupport::Notifications.subscribe('client_api_builder.request') do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
client = event.payload[:client]
puts "#{client.request_options&.dig(:method)} #{client.request_options&.dig(:uri)}"
puts "Status: #{client.response&.code}"
puts "Duration: #{event.duration.round(2)}ms"
puts "Failed: #{event.payload[:exception_object].inspect}" if event.payload[:exception]
end
# Or use the built-in log subscriber
subscriber = ClientApiBuilder::ActiveSupportLogSubscriber.new(Rails.logger)
subscriber.subscribe!An event fires for every attempt, including failed ones. When an attempt raises, the payload also holds :exception ([class name, message]) and :exception_object, following the usual ActiveSupport convention, and request_options or response may be nil. The built-in subscriber logs lines like:
GET https://api.example.com/users/123[200] took 45ms
GET https://api.example.com/users/123[UNKNOWN] took 5003ms (Net::ReadTimeout: Net::ReadTimeout)
Separately, ClientApiBuilder.logger receives every exception raised during a request attempt, including ones that are retried:
ClientApiBuilder.logger = Logger.new($stdout)Production Logging
The built-in log subscriber already leaves out query strings, which may hold credentials. To customize the format, subscribe directly:
ActiveSupport::Notifications.subscribe('client_api_builder.request') do |_, start_time, end_time, _, payload|
client = payload[:client]
method = client.request_options[:method].to_s.upcase
uri = client.request_options[:uri]
response_code = client.response ? client.response.code : 'UNKNOWN'
duration = ((end_time - start_time) * 1000).to_i
Rails.logger.info "#{method} #{uri.scheme}://#{uri.host}#{uri.path}[#{response_code}] took #{duration}ms"
endThis produces clean log entries like:
GET https://api.example.com/users/123[200] took 45ms
POST https://api.example.com/auth/token[201] took 120ms
Security Features
Client API Builder includes several security features enabled by default:
SSL/TLS Verification
HTTPS connections verify SSL certificates using OpenSSL::SSL::VERIFY_PEER and default to a 30 second open timeout and 60 second read timeout. Plain HTTP connections use Net::HTTP's own defaults.
SSRF Protection
Base URLs must use the http or https scheme and include a host, preventing Server-Side Request Forgery attacks:
class MyApiClient
include ClientApiBuilder::Router
base_url 'https://api.example.com' # Valid
base_url 'http://api.example.com' # Valid
base_url 'file:///etc/passwd' # Raises ArgumentError
base_url 'ftp://example.com' # Raises ArgumentError
endThe URL actually used is checked again on every request, so a base_url method defined on the client (for example, a per-tenant URL) gets the same check. A client with no base URL at all raises ArgumentError: no base_url configured for MyApiClient instead of failing inside Net::HTTP.
Path Value Encoding
Values inserted into a route's path are percent-encoded, so input such as ../admin or a/b?x=1 can't add path segments or a query string to the request.
Path Traversal Protection
File streaming rejects a path with a .. segment or a null byte, so a file name built from untrusted input can't climb out of the directory you put it in:
# These raise ArgumentError
client.download_file(id: 1, file: "/downloads/#{'../etc/passwd'}")
client.download_file(id: 1, file: "/tmp/file\0.txt")
# Names that only contain dots are fine
client.download_file(id: 1, file: '/downloads/report..v2.csv')Absolute paths and symlinks are not restricted, since only your code knows where files may go. If untrusted input can supply the whole path, validate it before passing it as file:.
Safe File Modes
Only safe file modes are allowed for streaming to files: w, wb, a, ab, w+, wb+, a+, ab+.
Thread Safety
Client instances are not thread-safe. Create a separate client instance per thread. To share connections between those instances, see Transports.
# Correct: Create a new client for each thread
threads = 5.times.map do |i|
Thread.new do
client = MyApiClient.new
client.get_user(id: i)
end
end
threads.each(&:join)
# Incorrect: Do not share clients across threads
client = MyApiClient.new
threads = 5.times.map do |i|
Thread.new do
client.get_user(id: i) # Race condition!
end
endConfiguration Reference
Class-Level Methods
| Method | Description |
|---|---|
base_url(url) |
Set the base URL for all requests |
header(name, value = nil, &block) |
Add a header to all requests (value, method name symbol, or block) |
body_builder(builder) |
Configure request body serialization |
query_builder(builder) |
Configure query string serialization |
query_param(name, value = nil, &block) |
Add a query parameter to all requests (value, method name symbol, or block) |
connection_option(name, value) |
Set Net::HTTP connection options |
configure_retries(max_attempts, sleep = 0.05, backoff: 1, max_sleep: nil, jitter: false) |
Configure retry behavior |
configure_exponential_retries(attempts:, initial: 0.1, max: 10, multiplier: 2, jitter: false) |
Configure retries with exponential backoff |
connection_pool(**settings) |
Configure persistent connection pools (requires include ClientApiBuilder::ConnectionPools) |
connection_per_thread(**settings) |
Configure persistent per-thread connections (requires include ClientApiBuilder::ThreadConnections) |
close_connections |
Close the class's pooled or per-thread connections and its sections' (with ConnectionPools or ThreadConnections) |
section_routers |
Section router classes by name |
route(name, path, options) |
Define an API endpoint |
section(name, options, &block) |
Define nested routes; inherit: opts into the root client's :headers, :query_params and/or :connection_options
|
namespace(path, &block) |
Add path prefix to routes in block |
Instance Methods
| Method | Description |
|---|---|
response |
Last Net::HTTPResponse object |
request_options |
Options used for last request |
total_request_time |
Duration of last request in seconds |
request_attempts |
Number of attempts for last request |
root_router |
Returns the root router (for nested routers) |
base_url |
Base URL used for requests |
Overridable Hooks
Define these in your client to change default behavior:
| Method | Default |
|---|---|
retry_request?(exception, options) |
true for the network errors listed under Retry Configuration |
with_http_connection(uri, connection_options, &block) |
Yields a started Net::HTTP (or an HTTP/2 connection) from the client's transport |
escape_path(value) |
Percent-encodes path values (ERB::Util.url_encode) |
parse_response(response, options) |
Parses the body as JSON, nil when empty |
handle_response(response, options, &block) |
Applies return:, parsing and the response block |
expected_response_code!(response, codes, options) |
Raises UnexpectedResponse for unexpected codes |
get_retry_request_max_retries(options) |
retries: option, then configure_retries, then 1 |
get_retry_request_sleep_time(exception, options) |
sleep: option, then configure_retries, then 0.05; multiplied by backoff**(attempt - 1), capped at max_sleep, then shortened by jitter
|
Requirements
- Ruby 3.3+
-
inheritance-helpergem (1.x) -
activesupport(optional) forto_querybuilders and instrumentation
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/dougyouch/client-api-builder.
- Fork the repository
- Create your feature branch (
git checkout -b feature/my-feature) - Write tests for your changes
- Ensure all tests pass with full line and branch coverage (
CI=true bundle exec rspec) - Ensure code style compliance (
bundle exec rubocop) - Commit your changes using conventional commits (
git commit -am 'feat(router): add my feature'); release notes and version bumps are generated from them - Push to the branch (
git push origin feature/my-feature) - Create a Pull Request
License
The gem is available as open source under the terms of the MIT License.