# 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.

Source: https://voxgig.com/howto/ruby-sdk-keyword-arguments-and-faraday-middleware

- Audience: api-producer
- Level: intermediate
- Languages: ruby
- Verified: 2026-09-24
- Published: 2026-09-24

## 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`](https://docs.ruby-lang.org/en/3.3/Data.html) 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](https://rspec.info/) nor [WebMock](https://github.com/bblimke/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](https://lostisland.github.io/faraday/#/middleware/index) | 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](https://openapi-generator.tech/docs/generators/ruby/) | 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](https://github.com/stripe/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](https://github.com/twilio/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.

```ruby title="lib/meterco.rb"
  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](https://docs.ruby-lang.org/en/3.3/syntax/calling_methods_rdoc.html) 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.

```ruby title="lib/meterco.rb"
    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.

```bash
ruby demo.rb
```

```text output
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.

```ruby title="lib/meterco.rb"
    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](https://lostisland.github.io/faraday/#/middleware/custom-middleware) sets out,
two methods on a subclass, and passes it in.

```ruby title="faraday_demo.rb"
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](https://lostisland.github.io/faraday/#/adapters/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.

```ruby title="test.rb"
  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
```

```bash
ruby test.rb
```

```text output
7 runs, 22 assertions, 0 failures, 0 errors, 0 skips
```

Seven tests, with [`minitest`](https://rubygems.org/gems/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](https://www.rfc-editor.org/rfc/rfc3986.html) 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](https://github.com/ruby/rbs) or [Sorbet](https://sorbet.org/) 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

- [Paginate an API with a Ruby enumerator](/howto/paginate-an-api-with-a-ruby-enumerator)

- [Decide between generating and hand-writing a client](/howto/generate-or-hand-write-a-small-api-client)

## 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.

[^1]: The RubyGems listings settle it. [stripe](https://rubygems.org/gems/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`](https://rubygems.org/gems/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.

[^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`](https://docs.ruby-lang.org/en/3.3/Data.html),
[added in Ruby 3.2](https://www.ruby-lang.org/en/news/2022/12/25/ruby-3-2-0-released/), 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.

[^3]: Ruby's [own announcement](https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/)
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.