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 +7 -0
- data/CHANGELOG.md +149 -0
- data/DESIGN.md +418 -0
- data/LICENSE.txt +21 -0
- data/README.md +174 -0
- data/exe/keiyaku +56 -0
- data/lib/keiyaku/adapters/faraday.rb +43 -0
- data/lib/keiyaku/adapters/http.rb +32 -0
- data/lib/keiyaku/adapters/net_http.rb +52 -0
- data/lib/keiyaku/emitter/rbs.rb +201 -0
- data/lib/keiyaku/emitter/security.rb +125 -0
- data/lib/keiyaku/emitter.rb +968 -0
- data/lib/keiyaku/names.rb +146 -0
- data/lib/keiyaku/runtime.rb +932 -0
- data/lib/keiyaku/version.rb +5 -0
- data/lib/keiyaku.rb +7 -0
- data/sig/keiyaku.rbs +216 -0
- metadata +67 -0
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.
|