How-to › Ship an SDK

How to give a Ruby SDK keyword arguments and Faraday middleware#

Give a Ruby client keyword-argument methods and Data response objects, let callers add Faraday middleware, and prove a wrong keyword raises before any request.

Audience
API producer
Level
intermediate
Topic
Design SDK ergonomics per language
Languages
Ruby
Verified

Your Ruby client takes an options hash, so get_meter(meter_id: 'mtr_1') runs, sends GET /v1/meters/ with an empty id, and comes back 404. The caller reads the API reference, finds the parameter is called id, and files the bug against the API. Nothing in the SDK could have told them, because a hash accepts any key.

What you get

You will end up with a client whose operations are keyword-argument methods, whose responses are immutable objects with readers, and whose transport takes Faraday middleware from the caller. A test proves a wrong keyword never becomes a request. This is for you if you ship a Ruby SDK and want it to feel written rather than generated.

Short answer

Define each operation as a method with keyword arguments and return Data.define objects with attribute readers. Build the transport as a Faraday connection whose block the caller controls, so middleware for logging or tracing is one use line. A wrong or missing keyword then raises ArgumentError before a request exists, which a test with a recording connection proves by counting zero calls.

You will need

Ruby 3.2 or later, because Data arrived in 3.2, Faraday 2 for the transport, and minitest, which ships with Ruby. Verified 2026-09-24 against Ruby 3.3.6, Faraday 2.14.0 and minitest 5.20.0. The checked commands on this page need Ruby alone. The tests inject a recording connection in Faraday’s shape, so neither RSpec nor WebMock is required, and the Faraday demonstration is the one file that loads the gem.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Faraday middlewareCallers need logging, tracing or auth of their own without forking the clientA dependency on Faraday and its adapter split, in every application that installs youA library that must load with no gems beyond the standard library
OpenAPI Generator ruby with FaradayThe spec changes weekly and the client has to follow it the same dayUniform, generated Ruby: positional hashes, configuration objects, and a library option to reach FaradayRuby developers are the audience and idiom is the product
Stripe RubyA hand-written client over the standard library, with no HTTP gem in its dependency listEvery transport feature is yours to write, retries and instrumentation includedYou want callers to bring their own middleware
Twilio RubyA hand-written client over Faraday with a configure_connection hook for middlewareThe Faraday, JWT and Nokogiri gems arrive with it, whether the caller wanted them or notA client small enough that a dependency is most of its weight

Stripe and Twilio are both hand-written, and they answer the transport question in opposite ways.1 Stripe’s gem lists bigdecimal and logger as its runtime dependencies and nothing else, so it carries the standard library’s HTTP and every feature above it. Twilio’s carries Faraday and exposes the connection to the caller. The generated client can be rerun on every spec change, and the hashes it takes are the shape this page argues against.

Make the keyword the contract

An operation is a method. Its keywords are the parameters the API reference lists, spelt the same way, and Ruby checks them at the call.

  class Client
    def initialize(api_key:, base_url: 'https://api.meterco.example/v1', connection: nil, &middleware)
      @api_key = api_key
      # The prefix stays on the path, because a path with a leading slash is
      # resolved against the origin and would drop it (RFC 3986, section 5.2).
      @prefix = URI(base_url).path.delete_suffix('/')
      @connection = connection || faraday_connection(base_url, &middleware)
    end

    def get_meter(id:)
      meter(request(:get, "/meters/#{id}"))
    end

    def list_meters(state: nil, limit: 20)
      query = { state: state, limit: limit }.compact
      request(:get, '/meters', query: query).map { |row| meter(row) }
    end

    def update_meter(id:, state:)
      meter(request(:patch, "/meters/#{id}", body: { state: state }))
    end

Keyword arguments with no default are required, so get_meter cannot be called without an id, and one with a default is optional, so list_meters can be. An unknown keyword is refused by name. All three checks happen in the caller’s frame, before the method body runs, which is why no request can exist when they fail.

The response is a Data object built from the members it declares.

    def meter(row)
      Meter.new(**row.slice(*Meter.members))
    end

Data.define(:id, :serial, :state) gives readers, equality, inspect and with, and refuses assignment.2 The slice matters more than it looks: Data.new raises on an unknown keyword, so a field the API adds next quarter would break every caller if the row went in whole.

ruby demo.rb
get_meter(id: 'mtr_1')                   #<data Meterco::Meter id="mtr_1", serial="SN-40199", state="installed">
meter.state                              "installed"
meter.with(state: 'retired')             #<data Meterco::Meter id="mtr_1", serial="SN-40199", state="retired">
requests so far                          1

get_meter(meter_id: 'mtr_1')             ArgumentError: missing keyword: :id
get_meter()                              ArgumentError: missing keyword: :id
update_meter(id: 'mtr_1', stat: 'x')     ArgumentError: missing keyword: :state
meter.stat                               NoMethodError: undefined method stat for Meterco::Meter
requests so far                          1

legacy.get_meter(meter_id: 'mtr_1')      Meterco::Error: meterco: 404
the request it sent                      GET /v1/meters/
requests so far                          2

The middle block is the page. The four wrong calls raise four exceptions without sending a request, so the request counter still reads 1. The last block is the options-hash client from the problem statement, and its counter moves: the typo became an empty id, the request went out, and the server answered.

Hand the connection to the caller

The client builds a Faraday connection unless it is given one, and the block it takes is Faraday’s own builder block.

    def faraday_connection(base_url, &block)
      require 'faraday'
      origin = URI(base_url)
      Faraday.new(url: "#{origin.scheme}://#{origin.host}") { |f| block&.call(f) }
    end

A caller who wants a trace writes a middleware in the shape Faraday’s documentation sets out, two methods on a subclass, and passes it in.

class Trace < Faraday::Middleware
  def on_request(env)
    options[:lines] << "-> #{env.method.to_s.upcase} #{env.url.path} #{env.request_headers['authorization']}"
  end

  def on_complete(env)
    options[:lines] << "<- #{env.status} #{env.response_headers['content-type']}"
  end
end

stubs = Faraday::Adapter::Test::Stubs.new do |stub|
  stub.get('/v1/meters/mtr_1') { [200, { 'content-type' => 'application/json' }, '{"id":"mtr_1","serial":"SN-40199","state":"installed"}'] }
end

lines = []
client = Meterco::Client.new(api_key: 'sk_test_1') do |f|
  f.use Trace, lines: lines
  f.adapter :test, stubs
end

Run against Faraday 2.14.0, that file prints the meter, then ArgumentError: missing keyword: :id for a second call with the wrong keyword, then the two trace lines -> GET /v1/meters/mtr_1 Bearer sk_test_1 and <- 200 application/json, and then the middleware saw 1 request(s). One request reached the stack; the wrong call never did. The test adapter is Faraday’s own, so the demonstration needs no network and no stubbing gem.

Check it worked

The test that matters counts requests, not exceptions. An ArgumentError on its own could have been raised after a request was built.

  def test_a_wrong_keyword_raises_before_any_request_is_built
    err = assert_raises(ArgumentError) { @client.get_meter(meter_id: 'mtr_1') }
    assert_match(/missing keyword: :id/, err.message)
    assert_equal 0, @conn.calls.size, 'no request reached the connection'
  end
ruby test.rb
7 runs, 22 assertions, 0 failures, 0 errors, 0 skips

Seven tests, with minitest. The other six cover the extra keyword, the bearer header, the missing reader, the field the API added, the nil left out of the query, and the legacy client’s misspelled request. That last one is the behavior the page argues you out of, pinned so nobody can claim it was never shown.

When it goes wrong

Every request goes to /meters and the /v1 prefix has vanished. The connection was built with the full base URL and the client passed paths with a leading slash, which RFC 3986 resolves against the origin, dropping the base path. That is why faraday_connection hands Faraday the origin alone and the client keeps the prefix on every path. The first version of this sample had the bug, and the Faraday run found it.

A response with a new field raises unknown keyword. The row went into Data.new whole. Slice it to the declared members, as meter does, and the client survives the API growing.

The typo is reported as a missing keyword rather than an unknown one. Ruby checks the required keywords first, so get_meter(meter_id: 'mtr_1') says missing keyword: :id and never mentions meter_id. The message still names the parameter the caller needs, and the test asserts on that text rather than on the word unknown.

A caller passes a hash and gets wrong number of arguments. Since Ruby 3.0, a hash in the last position is a positional argument, not keywords.3 Callers who hold options in a hash write get_meter(**opts), and the keyword check still runs over what the hash contains.

When not to do this

Do not switch a shipped options-hash API to keywords in one release. Every existing call passes a positional hash, which means the switch breaks all of them. Add the keyword methods beside the old ones, deprecate the hash, and remove it on a major version.

Do not take **opts to stay flexible. It is the options hash with different punctuation, and it gives up the required-argument check, the unknown-keyword refusal, and the signature a type checker can read. The signature file in the sample says (?Hash[Symbol, untyped] opts) for the legacy method and (id: String) -> Meter for the new one, and only one of those tells RBS or Sorbet anything.

Do not make Faraday a dependency of a client that will be embedded in other gems. Twilio’s choice is a fair one for an application-facing SDK. A library that other libraries load pays for every gem it brings, and Stripe’s choice of the standard library is the other fair answer.

Do not hand-write the client when the spec moves faster than you do. OpenAPI Generator’s ruby target is uniform rather than idiomatic, and it can be rerun the same afternoon the spec changes, which a hand-written client cannot.

Last verified

Verified 2026-09-24 against Ruby 3.3.6, Faraday 2.14.0 and minitest 5.20.0. Both output blocks are what the preceding command printed, against the recording connection in the sample directory. faraday_demo.rb was run against Faraday 2.14.0 with its test adapter, and its lines are quoted in the prose rather than captured, because the checked commands are kept to Ruby’s standard library. sig/meterco.rbs was validated with rbs -I sig validate under rbs 4.2.0.

Footnotes

  1. The RubyGems listings settle it. stripe 19.6.2 declares two runtime dependencies, bigdecimal and logger, both of them gems that were once part of the standard library. twilio-ruby 7.11.2 declares faraday, jwt and nokogiri. Although the two companies are of similar size and both wrote their Ruby clients by hand, they gave the same question two different answers, each with a straight face. Neither README argues the point; the dependency list does it for them. ↩︎ Back to text

  2. Data is the third attempt at a value object in Ruby’s standard library. Struct came first and is mutable. Struct with keyword_init: true came later and is still mutable. Data, added in Ruby 3.2, takes keywords by default, freezes the instance, and offers with to make a changed copy. That is the whole idea stated three times, each time a little more firmly. ↩︎ Back to text

  3. Ruby’s own announcement of the separation, dated December 12, 2019, opens by saying that positional and keyword arguments will be separated in 3.0. Ruby 2.7, it adds, would warn about every call the change would break. The options hash survived a decade as a convention because the language converted it to keywords on the way in, without being asked. Since 3.0 the conversion is gone and the convention has to be spelt **opts, two characters that say the hash was always a hash. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.