A long-lived project that still receives updates
Client API Builder generates HTTP client methods from a declarative route DSL. It infers HTTP methods from route names, builds query strings and request bodies, and supports nested routers, configurable retries, and streaming responses to files or IO. SSL verification, base URL scheme checks, and path traversal protection are on by default. Optional ActiveSupport integration adds instrumentation and request logging.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

 Project Readme

Client API Builder

Gem Version CI Coverage Branch Coverage

Changelog ยท Upgrading to 1.0

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-2 gem)
  • 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 install

Or install it yourself:

$ gem install client-api-builder

Quick 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', options

Options:

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
end

Dynamic 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_invoices

The 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('/')
end

2. 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
end

A 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
end

String 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.list

A 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
end

Inherited 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
end

Any 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 shutdown

A request uses the first transport it finds:

  1. The one set on the client instance with transport=.
  2. The one registered with use_transport for the client's class or its nearest ancestor (a module works too, for every class including it).
  3. The one the class itself includes (ConnectionPools, ThreadConnections or HTTP2, below), or its nearest ancestor's.
  4. For a section, its root client's transport.
  5. The process default set with use_transport(transport), else OneShot.

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
end

Every 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 raises ClientApiBuilder::ConnectionPools::TimeoutError.

  • With ConnectionPools, sections use their root client's pools, getting a separate pool for any other host in their base_url. Subclasses share their parent's pools unless they call connection_pool themselves.

  • A section can call connection_pool in 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 (or MyApiClient.close_connections, with ConnectionPools) closes idle connections now and in-use ones when they're returned. close_connections also 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
end

To 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 call connection_per_thread in its block to get its own.
  • After a fork, the child process opens its own connections. transport.close (or MyApiClient.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'
end

Every 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_timeout for a stream to free up.
  • Gzip and deflate responses are inflated, as Net::HTTP does, unless you set Accept-Encoding yourself.
  • A stream the server refused, or never processed before closing the connection (GOAWAY), raises ClientApiBuilder::HTTP2::StreamRefused; a dropped connection raises ClientApiBuilder::HTTP2::ConnectionLost. Both are retried by configure_retries. A stream the server reset raises ClientApiBuilder::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 (or MyApiClient.close_connections, with include 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
end

The 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
end

initial 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: true

retries:, 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
end

Streaming 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)
end

Streaming 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}"
end

Debugging

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"
end

This 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
end

The 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
end

Configuration 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-helper gem (1.x)
  • activesupport (optional) for to_query builders and instrumentation

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/dougyouch/client-api-builder.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/my-feature)
  3. Write tests for your changes
  4. Ensure all tests pass with full line and branch coverage (CI=true bundle exec rspec)
  5. Ensure code style compliance (bundle exec rubocop)
  6. Commit your changes using conventional commits (git commit -am 'feat(router): add my feature'); release notes and version bumps are generated from them
  7. Push to the branch (git push origin feature/my-feature)
  8. Create a Pull Request

License

The gem is available as open source under the terms of the MIT License.