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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Faraday middleware | Callers need logging, tracing or auth of their own without forking the client | A dependency on Faraday and its adapter split, in every application that installs you | A library that must load with no gems beyond the standard library |
| OpenAPI Generator ruby with Faraday | The spec changes weekly and the client has to follow it the same day | Uniform, generated Ruby: positional hashes, configuration objects, and a library option to reach Faraday | Ruby developers are the audience and idiom is the product |
| Stripe Ruby | A hand-written client over the standard library, with no HTTP gem in its dependency list | Every transport feature is yours to write, retries and instrumentation included | You want callers to bring their own middleware |
| Twilio Ruby | A hand-written client over Faraday with a configure_connection hook for middleware | The Faraday, JWT and Nokogiri gems arrive with it, whether the caller wanted them or not | A 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.
Related how-tos
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
-
The RubyGems listings settle it. stripe 19.6.2 declares two runtime dependencies,
bigdecimalandlogger, both of them gems that were once part of the standard library.twilio-ruby7.11.2 declaresfaraday,jwtandnokogiri. 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 -
Datais the third attempt at a value object in Ruby’s standard library.Structcame first and is mutable.Structwithkeyword_init: truecame later and is still mutable.Data, added in Ruby 3.2, takes keywords by default, freezes the instance, and offerswithto make a changed copy. That is the whole idea stated three times, each time a little more firmly. ↩︎ Back to text -
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