keiyaku 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 03bf89e5b76c37045f10c2b753071967c169d4de12ad428fc778cfbd914db2be
4
+ data.tar.gz: d191c7f6da38115bdeff90933561694081700d701f18b01b635f4b7a7da2cf07
5
+ SHA512:
6
+ metadata.gz: 676656c461c9ad9d0866ad9c9ae3eeb1f4d36cfed8853c7270c8dc3372f1503b720760b4b509dc95ff6f38b4dbee9db13a6767275f79af98176a8bd807bcc07b
7
+ data.tar.gz: 6fa784f8019ccce7a772cb2ea7d2dff1371b3159782ce4820274f7cb712195f6ed4f48ea10874d1f86b26e516714f1dbb4c1a5101c87a12a7286fd0bb7ba3632
data/CHANGELOG.md ADDED
@@ -0,0 +1,149 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-07-28
4
+
5
+ First cut. Generates a client, its value types and an RBS file from an OpenAPI
6
+ 3.0 or 3.1 document.
7
+
8
+ - `Keiyaku::Client` DSL: one line per operation, real method arity
9
+ - `Keiyaku.model`: schemas as `Keiyaku::Model` subclasses — frozen, compared by
10
+ value, copied with `#with`, pattern-matched. Casting a response ignores
11
+ fields the document never mentioned; constructing one refuses a keyword it
12
+ does not know, which is a typo rather than a server that has moved on
13
+ - `additionalProperties` is carried rather than dropped: a schema that says
14
+ there may be properties it did not name gets a model that keeps them, read
15
+ through the same `[]` as a property Ruby will not take through a dot, and
16
+ written back under the names they arrived with. A schema that says nothing
17
+ stays as strict as it was
18
+ - `style`/`explode` defaults (`form` for query, `simple` for path and header)
19
+ - `style: deepObject` query parameters, which go out as `filter[status]=live`.
20
+ The style has one rendering in the specification and it is an object's, so
21
+ that is what is generated: an array under `deepObject`, which is what Stripe
22
+ writes on `expand`, is refused rather than guessed at, and so is a value
23
+ nested a level deeper than the specification describes
24
+ - security is compiled per operation, from the operation's own requirement or
25
+ the document's: alternatives, schemes required together, and `security: []`.
26
+ Credentials are given by scheme name, and a scheme nothing can send refuses
27
+ only the operations that require it
28
+ - API key in a header, query parameter or cookie; bearer and basic; OAuth 2
29
+ and OpenID Connect access tokens, which are bearer tokens
30
+ - typed error bodies, mapped to `Keiyaku::ClientError` / `ServerError`; a
31
+ request that never happened is a `Keiyaku::ConnectionError` whichever adapter
32
+ it was, with the transport's own exception on `#cause`
33
+ - `timeout:` takes `{ open:, read: }` as well as one number for both
34
+ - constructs that cannot be translated faithfully are refused at generation
35
+ time and raise `Keiyaku::Unsupported` if called
36
+ - inline schemas are named for where the document wrote them — the operation
37
+ for a body or a response, the path down for a nested property — and two that
38
+ are structurally identical stay two types, since the name a structural match
39
+ keeps records where the generator walked rather than what the document says
40
+ - `oneOf`/`anyOf` with a `discriminator` becomes a `Keiyaku::OneOf`, including
41
+ the document's `mapping`; without one it stays `:any` and says so
42
+ - `multipart/form-data` request bodies, with `Keiyaku::Upload` for file parts
43
+ - optional adapters for faraday and http.rb, required explicitly rather than
44
+ depended on
45
+ - response header names are lower-cased before use, so an adapter that returns
46
+ them any other way still decodes
47
+ - retry backoff has jitter
48
+ - `Keiyaku.model` takes its fields as a positional Hash, so a property named
49
+ `from` or `required` cannot displace the option of the same name
50
+ - `anyOf` of a type and `null`, or of branches that all agree, keeps the type
51
+ instead of degrading to `:any`
52
+ - operations with no `operationId` are named for their verb and path; two that
53
+ would land on one name are both refused
54
+ - a document with no `servers` is a note, and building such a client without
55
+ `base_url:` raises rather than failing later inside `URI`
56
+ - a tuple (`items` as a list, or `prefixItems`) no longer crashes the generator
57
+ - every Ruby name goes through one table, which refuses what Ruby will not
58
+ take: a schema whose name is not a constant, an `operationId` that is a
59
+ keyword, two schemas or two parameters or two properties that normalise onto
60
+ one name
61
+ - several success responses have to agree on a type, or the operation is
62
+ refused rather than casting one status's body into another's model
63
+ - `$ref` is resolved with JSON Pointer escaping and only within the document:
64
+ one into another file is refused rather than silently naming a local schema
65
+ that happens to end in the same segment
66
+ - a schema that contains itself is typed lazily instead of referring to a
67
+ constant in the middle of its own definition
68
+ - the generator loads what it wrote, in another process, and reports a file
69
+ Ruby cannot read back as a bug in itself rather than leaving it to be found
70
+ at the first `require`
71
+ - a name the document chose reaches the generated files as data rather than as
72
+ text spliced into them. A query parameter called `id]` used to close the
73
+ `%i[]` it was written in and leave the rest of its own name to be read as
74
+ Ruby — by the load check first, since that runs what it just wrote, and by
75
+ every `require` of the client after it. The same for `deepObject` names and
76
+ for the refusals written as comments, which are now kept to the one line a
77
+ comment holds
78
+ - `--module` has to be one Ruby constant, said against the flag rather than
79
+ found later in a file that will not load
80
+ - which query and header parameters are required is declared once, as
81
+ `required: %i[status]`, rather than marked with a bang on the end of each
82
+ name. A document may name a parameter `notify!` itself, and the mark made
83
+ that one required and sent it as `notify`
84
+ - a response keyed by a range — `4XX`, which is how a document describes its
85
+ client errors in one go — is matched as one instead of read as the number 4.
86
+ It used to be written into the client unquoted, which is not Ruby: the file
87
+ did not parse, so every other operation in it was lost along with the one
88
+ that carried the range. The runtime reads the exact code first, then the
89
+ range that covers it, then `default`. A status that is none of the three is
90
+ refused, which costs the one operation rather than the file
91
+ - an operation's second error response is named for its status where the shapes
92
+ disagree, the way a second success response already was. Two errors of one
93
+ shape are still one type; two of different shapes used to refuse the
94
+ operation over the name they both derived
95
+ - a request body is optional unless the document required it, which is the
96
+ specification's default and so is the DSL's: `body_required: true` is written
97
+ down and its absence is the document's silence, rather than the other way
98
+ round. It used to be a positional argument either way, so an endpoint that
99
+ takes an empty body could only be called with a `nil` that went out as `null`
100
+ under a Content-Type. Left out now it is no body at all
101
+ - a request body under a vendor media type keeps it: `application/vnd.acme+json`
102
+ is built as the JSON its `+json` says it is, and sent under the name the
103
+ document gave rather than relabelled `application/json`, which a server
104
+ documenting the one is entitled to refuse. Where a document declares both,
105
+ the plain type is taken and the choice is a note
106
+ - a parameter an operation declares again is the document narrowing the path
107
+ item's — required here, optional elsewhere — rather than two parameters of
108
+ one name. It used to be both, which is one Ruby keyword written twice, and
109
+ the operation was refused for a duplicate the document never wrote
110
+ - `servers` on a path item or an operation is answered rather than ignored: it
111
+ is refused unless the client's own server is among the ones it lists, since
112
+ generating it would send that operation, and its credentials, to a host the
113
+ document did not name. Several at the top level is a note saying which was
114
+ taken, and a URL with variables in it is treated as no address at all rather
115
+ than sent to a host called `{region}`
116
+ - an error body is cast to whatever type the document declared for that status
117
+ rather than only to the ones that are a model. An error described as a list
118
+ of problems arrives as that list; one described as a map was calling
119
+ `Hash#cast`, and one the document left without a shape was calling
120
+ `Symbol#cast` — a `NoMethodError` in place of the error the server sent,
121
+ taking the status and the body with it, and matching no `rescue` the caller
122
+ could have written. A body that does not fit the type it was declared with
123
+ is kept as it arrived, since what answers a 502 is usually written by a
124
+ proxy that never read the document
125
+ - a path or header parameter that is not a scalar goes out in the `simple`
126
+ style the specification gives it: `1,2` for a list, and `role,admin` — or
127
+ `role=admin` where the document wrote `explode`, which the operation now
128
+ carries — for an object. It used to be whatever `#to_s` made of the value,
129
+ so a list reached the server as a string with brackets and a space in it and
130
+ an object as a Ruby Hash literal, neither of which anything reads back
131
+ - a request with no body reaches the server without a Content-Type on every
132
+ net/http there is. Net::HTTP gives a POST that was handed none an empty body
133
+ of its own, and net/http before 0.5 labels that empty body
134
+ `application/x-www-form-urlencoded` — so an optional body left out went out
135
+ under a media type this client never chose, on the Rubies whose standard
136
+ library is that old. The adapter says there is no body rather than leaving
137
+ one to be invented, and sends the length as the zero it is
138
+ - a path parameter is percent-encoded the way RFC 6570 says its style is: down
139
+ to the unreserved characters, and inside the separators rather than over
140
+ them. The whole segment used to go through `URI.encode_www_form_component`,
141
+ which is the query's encoding and not a path's — a space became `+`, which
142
+ in a path is a plus sign, and the comma between two elements of a list was
143
+ encoded along with any comma inside one, so that neither could be told from
144
+ the other at the far end
145
+ - `Retry-After` is read in both the forms RFC 7231 writes it: the seconds to
146
+ wait, and the date the wait is over. The date raised an `ArgumentError` from
147
+ the middle of the retry — out of the call the header was asking to have made
148
+ again — and one already past now means go now rather than a negative sleep.
149
+ A header in neither form is no instruction and leaves the wait to the backoff
data/DESIGN.md ADDED
@@ -0,0 +1,418 @@
1
+ # Design notes
2
+
3
+ Why keiyaku is shaped the way it is, and what it does with the parts of
4
+ OpenAPI that are not obvious. [README.md](README.md) is the shorter way in.
5
+
6
+ ## How it splits
7
+
8
+ Generated code carries only what is specific to one API: the operation table
9
+ and the schema fields. Everything else lives in `lib/keiyaku/runtime.rb` —
10
+ transport, auth, the `style`/`explode` parameter rules, body encoding, response
11
+ casting, error mapping, retries. The contract between the two is one method,
12
+ `Client#__invoke`, so the runtime can change without regenerating anything.
13
+
14
+ Terseness in `client.rb` costs nothing in tooling because the emitted RBS
15
+ carries the full signature:
16
+
17
+ ```rbs
18
+ def find_pets_by_status: (status: String) -> Array[Pet]
19
+ def update_pet_with_form: (Integer pet_id, ?name: String?, ?status: String?) -> Pet
20
+ def get_inventory: () -> Hash[String, Integer]
21
+ ```
22
+
23
+ The gem ships `sig/keiyaku.rbs` for the runtime itself, so a generated
24
+ `class Client < Keiyaku::Client` resolves rather than dangling. `rake rbs`
25
+ validates the examples against it, which is the check that would have caught
26
+ the generated RBS being subtly unresolvable.
27
+
28
+ That the signatures resolve is not yet that they are true. `rake steep` reads
29
+ [`checks/`](checks) — calling code for each example, which never runs — against
30
+ the RBS emitted beside that example, so a signature that has drifted from the
31
+ client it describes fails the build rather than somebody's editor.
32
+ [`checks/rejected.rb`](checks/rejected.rb) asks it from the other side: every
33
+ line there is wrong on purpose and names the complaint it has to draw, because
34
+ correct usage cannot tell a signature that is right from one that has quietly
35
+ gone `untyped` — both accept it. Steep reports an ignore comment it did not
36
+ need, so the day a type stops constraining what it says it constrains, the
37
+ assertion that it does goes redundant and the run fails.
38
+
39
+ The DSL builds real methods with real arity — `get :get_pet_by_id,
40
+ "/pet/{petId}"` defines `get_pet_by_id(pet_id)`, and calling it wrong raises
41
+ `ArgumentError` at the call, not a confusing failure inside the client.
42
+ `required:` says which query and header parameters a caller has to pass, by
43
+ the name the document gave them: `query: %i[status limit], required: %i[status]`.
44
+ It is said in one place rather than marked on each name, because a name is the
45
+ document's — one called `notify!` is a parameter called `notify!`, not an
46
+ optional one wearing the mark for a required one.
47
+
48
+ ## Why the model fields sit in a Hash
49
+
50
+ ```ruby
51
+ Pet = Keiyaku.model({
52
+ id: Integer, name: String, category: Category, photo_urls: [String],
53
+ tags: [Tag], status: String
54
+ }, required: %i[name photo_urls])
55
+ ```
56
+
57
+ The fields sit in a Hash of their own rather than as keywords beside
58
+ `required:`, because they are the API's names and not ours. A DIDComm message
59
+ has a property called `from`, which as a keyword would quietly have taken the
60
+ place of the option that maps JSON names.
61
+
62
+ ## Unions and uploads
63
+
64
+ A `oneOf` carrying a `discriminator` becomes a union that dispatches on it:
65
+
66
+ ```ruby
67
+ Event = Keiyaku::OneOf[WidgetCreated, WidgetRetired, on: "kind",
68
+ map: { "created" => WidgetCreated, "retired" => WidgetRetired }]
69
+ ```
70
+
71
+ and the RBS gets `type event = WidgetCreated | WidgetRetired`. Without a
72
+ discriminator there is nothing to dispatch on, so it stays `:any` and the run
73
+ says so — casting by trying each variant until one sticks would be a guess.
74
+
75
+ Some unions are only unions on paper, and those keep their type. `anyOf: [{type:
76
+ string}, {type: null}]` is how OpenAPI 3.1 says a field may be null, and a union
77
+ whose branches all resolve to the same type says nothing that type does not:
78
+ both become `String`. Neither is a guess, so neither is worth a note.
79
+
80
+ An `enum` is read as the type it restricts and nothing more: `{type: string,
81
+ enum: [available, pending, sold]}` is a `String`, in the model and in the RBS
82
+ alike. The values are not carried, not checked when a response is cast, and not
83
+ checked when a request goes out — a value the document does not list is a call
84
+ the client makes happily.
85
+
86
+ This is a gap rather than a decision about what the list means. Two things are
87
+ worth knowing about closing it. The values belong in the signature, where RBS
88
+ has literal types and `status: "avaliable"` can be a typo the typechecker
89
+ catches, and not in the cast, where enforcing them would break a working client
90
+ on a server adding a value — which OpenAPI permits and APIs do. And an `enum`
91
+ in JSON Schema is a list of values, not a list of strings: objects, arrays and
92
+ mixed types are all legal in one, and none of those has a literal to be written
93
+ as, so any version of this describes some lists and leaves the rest as the type
94
+ they already are.
95
+
96
+ A schema that says `additionalProperties` is a document telling you there will
97
+ be properties it did not name, and the model keeps them. DIDComm is the case in
98
+ hand: the specification has header extensions of its own and an implementation
99
+ may add more, so a client that dropped them could not forward a message it had
100
+ not composed itself.
101
+
102
+ ```ruby
103
+ Message = Keiyaku.model({ id: String, body: { String => :any } },
104
+ required: %i[id], open: true)
105
+
106
+ message = Message.cast(wire)
107
+ message.id # a declared property, through a dot
108
+ message["please_ack"] # one the schema never named
109
+ message.to_json_hash == wire # true — nothing was dropped on the way
110
+ ```
111
+
112
+ They are read through the same `[]` that reads a property whose name Ruby will
113
+ not take through a dot, so an open model has no API a closed one lacks. They
114
+ stay out of `members`, `to_h` and pattern matching, which describe the shape
115
+ the document actually specified. Their keys keep the spelling they arrived
116
+ with — a declared property has the document's name to map back to and an
117
+ undeclared one has nothing, so camelizing it would be a guess, and that is
118
+ what makes the round trip lossless.
119
+
120
+ `additionalProperties: {type: integer}` says what the values are, and the model
121
+ casts them. A schema that says nothing stays strict: absent is the great
122
+ majority of schemas, plenty of which do mean "and nothing else", and a model
123
+ that quietly accepted anything would accept a caller's typo along with it.
124
+
125
+ A `multipart/form-data` body is a model like any other, except that a property
126
+ with `format: binary` is typed `:upload`:
127
+
128
+ ```ruby
129
+ post :upload_photo, "/widgets/{id}/photo", multipart: UploadPhotoBody, into: Widget
130
+
131
+ client.upload_photo(1, Widgets::UploadPhotoBody.new(
132
+ file: File.open("kaya.png"), caption: "a dog", tags: %w[dog good]
133
+ ))
134
+ ```
135
+
136
+ A bare IO is wrapped in a `Keiyaku::Upload`, taking its filename from the path;
137
+ construct one yourself to set the filename or content type. An array property
138
+ becomes one part per element.
139
+
140
+ A body the document describes as text goes out as it stands, under the media
141
+ type the document named, and so does a binary one — neither is JSON, and the
142
+ only thing the generator has to know is what to put in the header.
143
+
144
+ An operation whose statuses are different types is all of them. GitHub answers
145
+ a request for a repository's contributor stats with the stats, or with a 202
146
+ meaning it is still counting; the document says which type belongs to which
147
+ status and the response carries the status, so nothing has to be guessed:
148
+
149
+ ```ruby
150
+ post :import_widgets, "/widgets/import", body: :text, content_type: "text/csv",
151
+ into: Keiyaku::ByStatus[200 => [Widget], 202 => ImportQueued]
152
+ ```
153
+
154
+ and the RBS returns `(Array[Widget] | ImportQueued)`, which is a caller that
155
+ has to look. A status the document did not describe is passed through undecoded
156
+ rather than cast into a type the server never claimed to be sending.
157
+
158
+ ## Transport
159
+
160
+ The default is `net/http`, and the gem has no dependencies. An adapter is one
161
+ method:
162
+
163
+ ```ruby
164
+ def call(verb, uri, headers, body) = [status, headers, body]
165
+ ```
166
+
167
+ Each lives in a file of its own, the stdlib one included: the runtime holds the
168
+ seam and none of the transport, and `keiyaku/adapters/net_http` is autoloaded by
169
+ the client that was given no adapter, so an application on another stack never
170
+ loads `net/http` at all.
171
+
172
+ Two more are shipped without being loaded, or depended on:
173
+
174
+ ```ruby
175
+ require "keiyaku/adapters/faraday"
176
+
177
+ client = Petstore::Client.new(adapter: Keiyaku::FaradayAdapter.new(connection))
178
+ ```
179
+
180
+ `keiyaku/adapters/http` is the same for http.rb. Neither gem is a dependency
181
+ and neither will become one: a client for one API has no business choosing an
182
+ HTTP stack for the application around it. Response header names come back in
183
+ whatever case the library uses; the runtime lower-cases them, so an adapter
184
+ cannot get that wrong. Built-in retries back off exponentially with jitter and
185
+ honour `Retry-After`; if Faraday is already carrying faraday-retry, build the
186
+ client with `retries: 0` and let the middleware do it properly.
187
+
188
+ A request that never happened — connection refused, DNS failure, a timeout —
189
+ is a `Keiyaku::ConnectionError` whatever is underneath, with the library's own
190
+ exception on `#cause`. Otherwise the seam leaks: an application that moved a
191
+ client from `net/http` to Faraday would find its `rescue` matching nothing, and
192
+ a refused connection taking the process down.
193
+
194
+ `timeout:` is one number for both phases or two:
195
+
196
+ ```ruby
197
+ Petstore::Client.new(timeout: { open: 2, read: 10 })
198
+ ```
199
+
200
+ Waiting to find out a host is not there and waiting for a slow answer are two
201
+ different patiences, and a client that sits mid-request inside somebody else's
202
+ application has to bound the first much more tightly than the second.
203
+
204
+ ## Refusing rather than guessing
205
+
206
+ The failure mode that matters is a generator emitting plausible code that is
207
+ subtly wrong. When this one cannot translate a construct faithfully it says so
208
+ at generation time and emits an operation that raises:
209
+
210
+ ```
211
+ 8/9 operations, 3 files
212
+
213
+ refused to generate 1 operation(s):
214
+ - list_widgets: query parameter expand uses style=deepObject on a schema that is not an object
215
+
216
+ Calling one raises Keiyaku::Unsupported. Write those by hand.
217
+ ```
218
+
219
+ What it refuses is anything where the generated code would be a guess: a name
220
+ Ruby will not take or that two things want at once, a `$ref` into a file it
221
+ cannot see, a body in an encoding it does not know, a security scheme it has
222
+ no way to send. Then, because the mistakes it does not know to look for
223
+ are still in the file it wrote, it reads that file back in another process and
224
+ reports what will not load as a bug in itself.
225
+
226
+ `servers` is the same judgement about an address. The client declares one and
227
+ every method it defines goes there, so a path item or an operation that names
228
+ a server of its own is refused unless the client's is among the ones it lists
229
+ — generating it anyway would send that operation's requests, and the
230
+ credentials on them, to a host the document did not name. Where the document
231
+ declares several at the top level the first is generated and the note says so,
232
+ and a URL with variables in it (`https://{region}.api.test`) is treated as no
233
+ address at all, since substituting a default would be the generator picking a
234
+ region.
235
+
236
+ The line it draws is the specification's, not a shortlist of what has been
237
+ implemented. `deepObject` is the clearest case. Its one row in OpenAPI's table
238
+ of styles is an object, exploded, and that is generated:
239
+
240
+ ```ruby
241
+ client.search_widgets(q: "kaya", filter: Widgets::SearchWidgetsFilter.new(status: "live"))
242
+ # GET /widgets/search?q=kaya&filter[status]=live
243
+ ```
244
+
245
+ The array and primitive columns of that row are written n/a, so an array under
246
+ `deepObject` — which is what Stripe writes on `expand`, on 351 parameters — is
247
+ not something the specification is quiet about, it is something the
248
+ specification says has no spelling. `expand[]=a` and `expand[0]=a` are both
249
+ plausible and neither is described, so that one is refused. Stripe's document
250
+ generates 352 of its 619 operations, and every one of the 267 it does not is
251
+ held up by exactly this.
252
+
253
+ ## Credentials
254
+
255
+ An operation is authenticated the way the document says that operation is
256
+ authenticated — which is not always the way the one above it is. The Petstore
257
+ declares two schemes and uses them differently across nineteen operations, so
258
+ credentials arrive by the name the document gave the scheme:
259
+
260
+ ```ruby
261
+ client = Petstore::Client.new(auth: { api_key: ENV["PETSTORE_KEY"] })
262
+
263
+ client.get_inventory # api_key: ...
264
+ client.get_pet_by_id(5) # api_key: ..., the first of two alternatives
265
+ client.add_pet(pet) # raises: this one documents petstore_auth
266
+ ```
267
+
268
+ A single value is allowed where the document declares one scheme, since naming
269
+ it would be ceremony. With two it is refused, because assigning it to whichever
270
+ came first is exactly how a client ends up sending an API key to the endpoints
271
+ that document OAuth. A client built with no credentials at all is taken at its
272
+ word and sends none: plenty of servers do not enforce what their document
273
+ declares, and that is not this library's call to make.
274
+
275
+ ## Documents nobody wrote by hand
276
+
277
+ A document a server produced from its own routes tends to be missing the things
278
+ a hand-written one has, and the generator has to cope rather than refuse.
279
+ [`examples/sidecar-inline.json`](examples/sidecar-inline.json) is one, checked in
280
+ exactly as the service printed it — ten routes, and every one of these true at
281
+ once:
282
+
283
+ - **No `operationId`.** The verb and the path are then all there is to name a
284
+ method with, so `POST /didcomm/pack/encrypted` becomes
285
+ `post_didcomm_pack_encrypted` and a path parameter becomes `by_id`, which
286
+ keeps `GET /things` and `GET /things/{id}` apart. Two operations that would
287
+ still land on one name are both refused, since whichever was written second
288
+ would take the method and every call meant for the other would go to a route
289
+ it does not name.
290
+ - **No `components`.** Every schema is then written out again under each
291
+ operation, and each copy becomes its own model named after the operation it
292
+ was written under. Ten routes over four shapes is seventy-one types rather
293
+ than the thirty-one that matching them up by structure would give, and the
294
+ forty extra are the price of never having chosen a name — see below.
295
+ - **No `servers`.** The address has to come from the application, so that is a
296
+ note at generation time and a `Keiyaku::Error` naming the client if one is
297
+ built without `base_url:`.
298
+
299
+ A document like this is worth more as a test than one written for the purpose,
300
+ because it was not written for the purpose. Every defect found in the generator
301
+ so far came from pointing it at that file rather than at the two hand-written
302
+ ones.
303
+
304
+ ### What naming them is worth
305
+
306
+ [`examples/sidecar.json`](examples/sidecar.json) is the same service after it
307
+ gave its eight shared shapes a `$id`, and nothing else about it changed: same ten
308
+ routes, still no `operationId`, still no `servers`, still printed rather than
309
+ written. So the pair is the one place here that isolates what a name does.
310
+
311
+ | | inline | named |
312
+ | --- | --- | --- |
313
+ | models | 71 | **32** |
314
+ | `types.rb` | 434 lines | **179 lines** |
315
+ | RBS | 686 lines | **302 lines** |
316
+ | notes | 13 | **4** |
317
+
318
+ The forty-odd models that go are the copies. A DIDComm message is one type
319
+ instead of four, so a caller can build a message and pack it encrypted, signed
320
+ and plaintext — with four types there was no value that more than one of the
321
+ three would take. The notes drop the same way: `Attachment.data` is an `anyOf`
322
+ with nothing to dispatch on once rather than four times.
323
+
324
+ It also settles a case the generator cannot settle itself. `POST /did/resolve`
325
+ answers 200 and 400 with the same shape, and in the inline document those are two
326
+ schemas that merely look alike, so they stay two types — matching them up would
327
+ hand a caller rescuing the error a type whose name said the call had succeeded.
328
+ In the named document both say `DIDResolutionResult`, which is the document
329
+ stating that they are one type, and the generator agrees because it was told
330
+ rather than because it guessed. Both examples pin their own side of that.
331
+
332
+ ## Where the names come from
333
+
334
+ Every type is named by reading the document, and the generator never picks
335
+ between two names that both fit. Where the document names a type — anything in
336
+ `components/schemas` — the name is its own. Where it does not, the position the
337
+ schema was written in names it: an operation's request body is that operation
338
+ plus `Body`, a success response plus `Result`, an error plus `Error`, and a
339
+ property nested inside one of those is its path down from it.
340
+
341
+ Two schemas that happen to be structurally identical are still two types. This
342
+ is the rule that costs the most — Stripe writes out all 601 of its request
343
+ bodies inline, and matching them up drops 5,014 models to 2,306 — and it is
344
+ still the right way round, because the name a structural match keeps is a
345
+ record of where the generator walked first rather than of anything in the
346
+ document. That is how 56 unrelated Stripe operations came to share a body type
347
+ called `PostAccountsAccountLoginLinksBody`, 25 more a type called after a card
348
+ mandate, and how the inline sidecar's `POST /did/resolve` came to declare its own
349
+ success type as its 400 body — a caller rescuing the error got a value whose
350
+ class said the call had succeeded.
351
+
352
+ The one thing that is still shared is a name derived twice. An operation's 400
353
+ and its 404 are both `#{name}Error`, and where the document gives them the same
354
+ shape they are one model; where it does not, the second is named for its status
355
+ — `#{name}404Error` — the way a second success response already was. Two names
356
+ derived from one document are not the same thing as two schemas matched up by
357
+ structure, which is what the rule above is about.
358
+
359
+ ## Tests
360
+
361
+ `rake` regenerates the examples, validates their RBS, type-checks calling code
362
+ against it, and runs the specs — 247 RSpec examples covering name mapping in
363
+ both directions, nested and array models, pattern matching, query array
364
+ explosion, header parameters overriding credentials, per-operation security,
365
+ typed error bodies, binary, text, vendor JSON and multipart request bodies,
366
+ discriminated unions, responses cast by their status and by the range their
367
+ status falls in, and cast errors naming the offending field.
368
+
369
+ Nothing is stubbed. The generated clients talk to a real HTTP server on a real
370
+ socket ([`spec/support/test_server.rb`](spec/support/test_server.rb)), which
371
+ also records what it was sent, so an example can assert on the request as well
372
+ as the response. A stubbed adapter would only agree with whatever the runtime
373
+ happened to do. The examples run in random order and each one starts with no
374
+ outstanding requests, so none of them depends on another having run first.
375
+
376
+ A second document, [`examples/widgets.yaml`](examples/widgets.yaml), exists to
377
+ keep the generator honest about not being fitted to the Petstore: OAS 3.1,
378
+ bearer auth, a `default` error response, and JSON fields that are already
379
+ snake_case (which the camelCase convention would otherwise guess wrong). Its
380
+ `POST /widgets/import` holds the three things a document is entitled to say
381
+ and Ruby is awkward about — a text body, a parameter called `until`, and two
382
+ success statuses that are two types — because each of them was a refusal until
383
+ GitHub's document turned one up. `POST /widgets/{id}/notes` holds three more
384
+ that were wrong rather than refused: a body under the server's own `+json`
385
+ media type, a body the document never required, and errors described as `4XX`
386
+ rather than one code at a time. It also carries both sides of the `deepObject`
387
+ line at once: `GET /widgets/search` takes one on an object and is generated,
388
+ `GET /widgets` takes one on an array and is the document's single refusal.
389
+
390
+ The third and fourth were not written for this at all — see above — and are two
391
+ prints of one service, before and after it named its shared shapes. Both clients
392
+ are generated and both RBS files validated on every run like the others.
393
+ [`spec/sidecar_inline_spec.rb`](spec/sidecar_inline_spec.rb) asserts that every
394
+ type in [`examples/sidecar-inline.json`](examples/sidecar-inline.json) is named
395
+ after the operation it was written under, which is the whole of the naming rule
396
+ on a document that names nothing itself;
397
+ [`spec/sidecar_spec.rb`](spec/sidecar_spec.rb) asserts that
398
+ [`examples/sidecar.json`](examples/sidecar.json) takes the eight names the
399
+ document does give, that the operations sharing a shape share a type, and that
400
+ what the document still leaves inline is still named by position.
401
+
402
+ The inline copy is frozen on purpose. It does not track the service and is not
403
+ refreshed from it — what it holds is not the sidecar but the shape of a document
404
+ with no `components` at all, which is a thing servers emit and which nothing else
405
+ here exercises.
406
+
407
+ The optional adapters are covered by the same suite, over the same socket, when
408
+ faraday and http.rb happen to be installed. They are marked pending otherwise,
409
+ since neither is a dependency.
410
+
411
+ Saying they are optional is worth nothing unless something checks it, so CI
412
+ runs the suite a second time against
413
+ [`gemfiles/bare.gemfile`](gemfiles/bare.gemfile), which carries neither gem: a
414
+ stray `require` in the runtime fails there rather than on somebody's first
415
+ install. A third job builds the gem, installs it into an empty prefix, and
416
+ generates a client from outside the checkout — nothing from the working tree is
417
+ on the load path, which is what catches a file the gemspec forgot to list. The
418
+ matrix runs 3.2, the floor `Data.define` puts the gemspec at, through 4.0.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 merely
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.