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
data/README.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# keiyaku
|
|
2
|
+
|
|
3
|
+
[](https://github.com/onyxblade/keiyaku/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
契約 — an OpenAPI-to-Ruby client generator built on the premise that almost
|
|
6
|
+
everything existing generators emit is boilerplate that belongs in a runtime
|
|
7
|
+
library. A spec is a contract; the generated code should be the part of it that
|
|
8
|
+
is actually specific to your API, and nothing else.
|
|
9
|
+
|
|
10
|
+
Generating the [Swagger Petstore](examples/petstore.yaml) (OAS 3.0.4, 19
|
|
11
|
+
operations, 6 schemas):
|
|
12
|
+
|
|
13
|
+
| | `openapi-generator -g ruby` | this |
|
|
14
|
+
| --- | --- | --- |
|
|
15
|
+
| generated Ruby | 4,316 lines across 25 files | **67 lines across 2 files** |
|
|
16
|
+
| RBS | none | 88 lines |
|
|
17
|
+
| shared runtime | — | 939 lines, written once |
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
gem "keiyaku"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
One gem, no dependencies. The runtime is what an application loads; the
|
|
26
|
+
generator is a separate file that only the executable pulls in, so nothing
|
|
27
|
+
that merely calls an API ever loads it.
|
|
28
|
+
|
|
29
|
+
## Generate
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
keiyaku petstore.yaml --module Petstore --out lib/petstore
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Three files land in the output directory: `types.rb`, `client.rb`, and an
|
|
36
|
+
`.rbs` carrying the type detail. Check them in — they are source, not build
|
|
37
|
+
output, and reading the diff is how you see what changed in an API.
|
|
38
|
+
|
|
39
|
+
`client.rb` is a table of operations, one line each:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
module Petstore
|
|
43
|
+
class Client < Keiyaku::Client
|
|
44
|
+
server "https://petstore3.swagger.io/api/v3"
|
|
45
|
+
security({ petstore_auth: :bearer, api_key: { header: "api_key" } })
|
|
46
|
+
|
|
47
|
+
put :update_pet, "/pet", body: Pet, into: Pet, security: :petstore_auth
|
|
48
|
+
post :add_pet, "/pet", body: Pet, into: Pet, security: :petstore_auth
|
|
49
|
+
get :find_pets_by_status, "/pet/findByStatus", query: %i[status], required: %i[status], into: [Pet], security: :petstore_auth
|
|
50
|
+
get :get_pet_by_id, "/pet/{petId}", into: Pet, security: [[:api_key], [:petstore_auth]]
|
|
51
|
+
delete :delete_pet, "/pet/{petId}", header: { "api_key" => :api_key }, security: :petstore_auth
|
|
52
|
+
get :get_inventory, "/store/inventory", into: { String => Integer }, security: :api_key
|
|
53
|
+
post :create_users_with_list_input, "/user/createWithList", body: [User], into: User
|
|
54
|
+
# ...
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`types.rb` is a value type per schema:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
Pet = Keiyaku.model({
|
|
63
|
+
id: Integer, name: String, category: Category, photo_urls: [String],
|
|
64
|
+
tags: [Tag], status: String
|
|
65
|
+
}, required: %i[name photo_urls])
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
and the RBS beside them carries the full signature, so terse Ruby costs nothing
|
|
69
|
+
in tooling:
|
|
70
|
+
|
|
71
|
+
```rbs
|
|
72
|
+
def find_pets_by_status: (status: String) -> Array[Pet]
|
|
73
|
+
def update_pet_with_form: (Integer pet_id, ?name: String?, ?status: String?) -> Pet
|
|
74
|
+
def get_inventory: () -> Hash[String, Integer]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Call
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
client = Petstore::Client.new(auth: { api_key: ENV["PETSTORE_KEY"] })
|
|
81
|
+
|
|
82
|
+
pet = client.get_pet_by_id(5) # => Petstore::Pet
|
|
83
|
+
pet.photo_urls # camelCase mapped to snake_case
|
|
84
|
+
pet.with(name: "Nori") # models are frozen values; copies are cheap
|
|
85
|
+
|
|
86
|
+
case client.find_pets_by_status(status: "available")
|
|
87
|
+
in [{ name:, category: { name: "Dogs" } }, *] then ...
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Methods have real arity — `get_pet_by_id(5)` with the wrong number of arguments
|
|
92
|
+
raises `ArgumentError` at the call, not somewhere inside the client.
|
|
93
|
+
Credentials are given by the name the document gave the scheme, and each
|
|
94
|
+
operation is authenticated the way the document says *that* operation is.
|
|
95
|
+
|
|
96
|
+
## What it covers
|
|
97
|
+
|
|
98
|
+
- **Models** — frozen value types, compared by value, copied with `#with`,
|
|
99
|
+
pattern-matched. Names are mapped in both directions; `additionalProperties`
|
|
100
|
+
are carried rather than dropped, and round-trip losslessly.
|
|
101
|
+
- **Parameters** — `form` and `deepObject` query styles, `simple` for path and
|
|
102
|
+
header, with the specification's `explode` defaults. `required:` says which a
|
|
103
|
+
caller has to pass.
|
|
104
|
+
- **Bodies** — JSON, a server's own `+json` media type, text, binary, and
|
|
105
|
+
`multipart/form-data` with file uploads.
|
|
106
|
+
- **Responses** — cast per status, including by range; `Keiyaku::ByStatus` for
|
|
107
|
+
an operation whose statuses are different types; typed error bodies mapped to
|
|
108
|
+
`Keiyaku::ClientError` / `ServerError`.
|
|
109
|
+
- **Unions** — a `oneOf`/`anyOf` with a `discriminator` becomes a
|
|
110
|
+
`Keiyaku::OneOf` that dispatches on it.
|
|
111
|
+
- **Security** — compiled per operation: alternatives, schemes required
|
|
112
|
+
together, `security: []`. API key in a header, query or cookie; bearer,
|
|
113
|
+
basic, OAuth 2 and OpenID Connect tokens.
|
|
114
|
+
- **Transport** — `net/http` by default, autoloaded only if used; adapters for
|
|
115
|
+
faraday and http.rb ship without either being a dependency. Retries back off
|
|
116
|
+
with jitter and honour `Retry-After`; `timeout:` takes `{ open:, read: }`; a
|
|
117
|
+
request that never happened is a `Keiyaku::ConnectionError` whatever is
|
|
118
|
+
underneath.
|
|
119
|
+
|
|
120
|
+
## What it refuses
|
|
121
|
+
|
|
122
|
+
The failure mode that matters is a generator emitting plausible code that is
|
|
123
|
+
subtly wrong. When this one cannot translate a construct faithfully it says so
|
|
124
|
+
at generation time and emits an operation that raises:
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
8/9 operations, 3 files
|
|
128
|
+
|
|
129
|
+
refused to generate 1 operation(s):
|
|
130
|
+
- list_widgets: query parameter expand uses style=deepObject on a schema that is not an object
|
|
131
|
+
|
|
132
|
+
Calling one raises Keiyaku::Unsupported. Write those by hand.
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
A name Ruby will not take, a `$ref` into a file it cannot see, an encoding it
|
|
136
|
+
does not know, a security scheme it has no way to send, an operation pointing
|
|
137
|
+
at a server the client does not declare — each is refused rather than guessed
|
|
138
|
+
at. The line it draws is the specification's: `deepObject` on an object is
|
|
139
|
+
described and is generated, `deepObject` on an array is written n/a in
|
|
140
|
+
OpenAPI's own table and is not.
|
|
141
|
+
|
|
142
|
+
## Not done yet
|
|
143
|
+
|
|
144
|
+
- `explode: false` query parameters are still refused, and so is every query
|
|
145
|
+
style but `form` and `deepObject` — `spaceDelimited` and `pipeDelimited` are
|
|
146
|
+
described by the specification and simply have not been written yet, which is
|
|
147
|
+
not the same as `deepObject` on an array, which never will be
|
|
148
|
+
- the document has to be one file. A `$ref` into another is refused rather
|
|
149
|
+
than resolved, so anything split up has to be bundled first — by something
|
|
150
|
+
else, since nothing here fetches a URL to find out what a type is
|
|
151
|
+
- `readOnly` and `writeOnly` are not read, so one schema is one model in both
|
|
152
|
+
directions. Until they are, `required:` cannot be enforced when a model is
|
|
153
|
+
constructed — the Petstore's `Pet.id` is required in a response and has no
|
|
154
|
+
business being set on a request, and one flag cannot mean both
|
|
155
|
+
- `enum` values are not carried: the type they restrict is generated and the
|
|
156
|
+
list itself is not checked in either direction
|
|
157
|
+
- there is no pagination. OpenAPI describes none, and a parameter named `page`
|
|
158
|
+
is not evidence of anything, so walking an operation has to be declared —
|
|
159
|
+
which for a document you did not write means a hints file, and there is none.
|
|
160
|
+
Until there is, the loop over the pages belongs to the application
|
|
161
|
+
- a client is built for one server. An operation that overrides it is refused
|
|
162
|
+
rather than sent somewhere else, and server variables have no substitution,
|
|
163
|
+
so both are for the application to supply through `base_url:`
|
|
164
|
+
- regeneration overwrites wholesale; there is no merge strategy for hand edits,
|
|
165
|
+
which bites hardest on the operations it refused and left you to write
|
|
166
|
+
|
|
167
|
+
## More
|
|
168
|
+
|
|
169
|
+
[DESIGN.md](DESIGN.md) is the long version: how the generated code and the
|
|
170
|
+
runtime split, what happens to unions, uploads and open models, where type
|
|
171
|
+
names come from, why a construct gets refused, and what the test suite is
|
|
172
|
+
pointed at. [CHANGELOG.md](CHANGELOG.md) is what has changed.
|
|
173
|
+
|
|
174
|
+
MIT licensed.
|
data/exe/keiyaku
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "optparse"
|
|
6
|
+
require "keiyaku/emitter"
|
|
7
|
+
|
|
8
|
+
options = { out: "." }
|
|
9
|
+
|
|
10
|
+
parser = OptionParser.new do |o|
|
|
11
|
+
o.banner = "usage: keiyaku SPEC --module NAME [--out DIR]"
|
|
12
|
+
o.separator ""
|
|
13
|
+
o.on("-m", "--module NAME", "Ruby module to nest the client and its types in")
|
|
14
|
+
o.on("-o", "--out DIR", "Directory to write into (default: the working directory)")
|
|
15
|
+
o.separator ""
|
|
16
|
+
o.on("-v", "--version", "Print the version and exit") { puts Keiyaku::VERSION; exit }
|
|
17
|
+
o.on("-h", "--help", "Print this message and exit") { puts o; exit }
|
|
18
|
+
end
|
|
19
|
+
parser.parse!(into: options)
|
|
20
|
+
|
|
21
|
+
spec = ARGV.shift
|
|
22
|
+
abort parser.to_s unless spec && options[:module]
|
|
23
|
+
abort "keiyaku: #{spec}: no such file" unless File.file?(spec)
|
|
24
|
+
|
|
25
|
+
FileUtils.mkdir_p(options[:out])
|
|
26
|
+
emitter =
|
|
27
|
+
begin
|
|
28
|
+
Keiyaku::Emitter.new(spec, namespace: options[:module])
|
|
29
|
+
rescue Keiyaku::Impossible => e
|
|
30
|
+
abort "keiyaku: #{e.message}"
|
|
31
|
+
end
|
|
32
|
+
operations = emitter.emit(options[:out])
|
|
33
|
+
|
|
34
|
+
built = operations.count { !_1[:unsupported] }
|
|
35
|
+
puts "#{built}/#{operations.size} operations, #{Dir[File.join(options[:out], "*")].size} files"
|
|
36
|
+
|
|
37
|
+
unless emitter.notes.empty?
|
|
38
|
+
puts "\nnotes:"
|
|
39
|
+
emitter.notes.uniq.each { puts " - #{_1}" }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
unless emitter.refusals.empty?
|
|
43
|
+
puts "\nrefused to generate #{emitter.refusals.size} operation(s):"
|
|
44
|
+
emitter.refusals.each { puts " - #{_1.operation}: #{_1.reason}" }
|
|
45
|
+
puts "\nCalling one raises Keiyaku::Unsupported. Write those by hand."
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The generator writing a file Ruby cannot read back is a defect in the
|
|
49
|
+
# generator, not in the document, and it exits non-zero so that a build does
|
|
50
|
+
# not carry on with a client nothing can require.
|
|
51
|
+
if emitter.broken
|
|
52
|
+
puts "\nthe generated client does not load:"
|
|
53
|
+
puts emitter.broken.lines.map { " #{_1}" }
|
|
54
|
+
puts "\nThis is a bug in keiyaku. The files are still there to look at."
|
|
55
|
+
exit 1
|
|
56
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require_relative "../runtime"
|
|
5
|
+
|
|
6
|
+
module Keiyaku
|
|
7
|
+
# Puts a generated client on Faraday, so it inherits the connection pooling,
|
|
8
|
+
# instrumentation and retry middleware an application has already set up.
|
|
9
|
+
#
|
|
10
|
+
# require "keiyaku/adapters/faraday"
|
|
11
|
+
#
|
|
12
|
+
# client = Petstore::Client.new(adapter: Keiyaku::FaradayAdapter.new(conn))
|
|
13
|
+
#
|
|
14
|
+
# Faraday is not a dependency of this gem, and will not become one: a client
|
|
15
|
+
# for one API has no business choosing an HTTP stack for the application
|
|
16
|
+
# around it. Requiring this file is how you opt in.
|
|
17
|
+
#
|
|
18
|
+
# With faraday-retry in the connection, build the client with `retries: 0`.
|
|
19
|
+
# The middleware handles Retry-After and jitter properly; the loop in the
|
|
20
|
+
# runtime is only there so the stdlib path is not defenceless.
|
|
21
|
+
class FaradayAdapter
|
|
22
|
+
def initialize(connection = nil, timeout: nil, **options)
|
|
23
|
+
if timeout
|
|
24
|
+
# Faraday has its own spelling for these, and a connection that was
|
|
25
|
+
# handed in already carries whatever it was built with. Accepting the
|
|
26
|
+
# option and dropping it would be the worst of the three.
|
|
27
|
+
raise ArgumentError, "timeout: has no effect on a connection you built; set it on that" if connection
|
|
28
|
+
|
|
29
|
+
open, read = Keiyaku.timeouts(timeout)
|
|
30
|
+
options[:request] = { open_timeout: open, timeout: read }.merge(options[:request] || {})
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@connection = connection || Faraday.new(**options)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call(verb, uri, headers, body)
|
|
37
|
+
response = @connection.run_request(verb, uri, body, headers)
|
|
38
|
+
[response.status, response.headers.to_h, response.body]
|
|
39
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError => e
|
|
40
|
+
raise ConnectionError, "#{verb.to_s.upcase} #{uri}: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "http"
|
|
4
|
+
require_relative "../runtime"
|
|
5
|
+
|
|
6
|
+
module Keiyaku
|
|
7
|
+
# Puts a generated client on http.rb, which is faster than Net::HTTP and can
|
|
8
|
+
# hold persistent connections.
|
|
9
|
+
#
|
|
10
|
+
# require "keiyaku/adapters/http"
|
|
11
|
+
#
|
|
12
|
+
# client = Petstore::Client.new(
|
|
13
|
+
# adapter: Keiyaku::HTTPAdapter.new(HTTP.persistent("https://petstore3.swagger.io"))
|
|
14
|
+
# )
|
|
15
|
+
#
|
|
16
|
+
# Like the Faraday one, this is an opt-in file rather than a dependency.
|
|
17
|
+
# Named after the gem it requires, not after what it does — otherwise there
|
|
18
|
+
# is no telling it apart from NetHTTPAdapter.
|
|
19
|
+
class HTTPAdapter
|
|
20
|
+
def initialize(client = nil, timeout: 15)
|
|
21
|
+
open, read = Keiyaku.timeouts(timeout)
|
|
22
|
+
@client = client || HTTP.timeout(connect: open, read:)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(verb, uri, headers, body)
|
|
26
|
+
response = @client.headers(headers).request(verb, uri, body.nil? ? {} : { body: })
|
|
27
|
+
[response.code, response.headers.to_h, response.body.to_s]
|
|
28
|
+
rescue HTTP::ConnectionError, HTTP::TimeoutError, *TRANSPORT_ERRORS => e
|
|
29
|
+
raise ConnectionError, "#{verb.to_s.upcase} #{uri}: #{e.message}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require_relative "../runtime"
|
|
5
|
+
|
|
6
|
+
module Keiyaku
|
|
7
|
+
# The stdlib adapter, which is what a client builds for itself when it was
|
|
8
|
+
# given none. Nothing has to require this file: the runtime autoloads it on
|
|
9
|
+
# that first default, so an application that named an adapter of its own
|
|
10
|
+
# never pays for net/http.
|
|
11
|
+
#
|
|
12
|
+
# A connection per request, since Net::HTTP holding one is a state an
|
|
13
|
+
# adapter with no state cannot keep. Where that cost matters, http.rb's
|
|
14
|
+
# persistent client or a Faraday connection is the answer, which is what
|
|
15
|
+
# those adapters are for.
|
|
16
|
+
class NetHTTPAdapter
|
|
17
|
+
def initialize(timeout: 15)
|
|
18
|
+
@open_timeout, @read_timeout = Keiyaku.timeouts(timeout)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(verb, uri, headers, body)
|
|
22
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
23
|
+
http.use_ssl = uri.scheme == "https"
|
|
24
|
+
http.open_timeout = @open_timeout
|
|
25
|
+
http.read_timeout = @read_timeout
|
|
26
|
+
|
|
27
|
+
request = Net::HTTP.const_get(verb.to_s.capitalize).new(uri)
|
|
28
|
+
headers.each { |k, v| request[k] = v }
|
|
29
|
+
|
|
30
|
+
if body
|
|
31
|
+
request.body = body
|
|
32
|
+
elsif request.request_body_permitted?
|
|
33
|
+
# No body is no body: an optional one left out means no bytes and no
|
|
34
|
+
# Content-Type claiming there are some, which is the runtime's promise
|
|
35
|
+
# and not this file's to give away. Net::HTTP hands a POST that was
|
|
36
|
+
# given none an empty body of its own, and net/http before 0.5 labels
|
|
37
|
+
# that application/x-www-form-urlencoded — a media type this client
|
|
38
|
+
# never chose, on a request that carries nothing. Saying so here is
|
|
39
|
+
# one method rather than a header to unpick later, and the length
|
|
40
|
+
# still goes out as the zero it is, since a POST without one is what
|
|
41
|
+
# some servers answer with 411.
|
|
42
|
+
request.content_length = 0
|
|
43
|
+
def request.request_body_permitted? = false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
response = http.request(request)
|
|
47
|
+
[response.code.to_i, response.to_hash.transform_values(&:first), response.body]
|
|
48
|
+
rescue *TRANSPORT_ERRORS => e
|
|
49
|
+
raise ConnectionError, "#{verb.to_s.upcase} #{uri}: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../names"
|
|
4
|
+
|
|
5
|
+
module Keiyaku
|
|
6
|
+
class Emitter
|
|
7
|
+
# The signature file, which is a second translation of the same document
|
|
8
|
+
# into a language with its own rules: `|` means one thing after `attr_reader`
|
|
9
|
+
# and another after `->`, a name that is not a method cannot be an
|
|
10
|
+
# attr_reader at all, and a component that turned out not to be a class is
|
|
11
|
+
# referred to by a type alias rather than by its constant. None of that has
|
|
12
|
+
# anything to do with what the Ruby emitter is deciding, so it is kept
|
|
13
|
+
# where it can be read as the one subject it is.
|
|
14
|
+
#
|
|
15
|
+
# It is handed the model and union tables rather than a copy of them,
|
|
16
|
+
# because a union component's expansion is worked out while the schemas are
|
|
17
|
+
# still being collected — the tables go on growing behind it.
|
|
18
|
+
class RBS
|
|
19
|
+
SCALARS = {
|
|
20
|
+
":bool" => "bool", ":any" => "untyped", ":binary" => "String", ":text" => "String",
|
|
21
|
+
":upload" => "Keiyaku::Upload | IO"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def initialize(namespace:, models:, unions:)
|
|
25
|
+
@namespace = namespace
|
|
26
|
+
@models = models
|
|
27
|
+
@unions = unions
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def type_for(source)
|
|
31
|
+
# A recursive type is written as a Proc in the source and as itself here:
|
|
32
|
+
# RBS has no trouble with a class that mentions its own name.
|
|
33
|
+
return type_for(source[/\A-> \{(.*)\}\z/m, 1].strip) if source.start_with?("-> {")
|
|
34
|
+
return "Array[#{type_for(source[1..-2].strip)}]" if source.start_with?("[")
|
|
35
|
+
return "Hash[String, #{type_for(source[/=>(.*)}/m, 1].strip)}]" if source.start_with?("{")
|
|
36
|
+
|
|
37
|
+
# A named union is referred to by its alias; an inline one is spelled out.
|
|
38
|
+
if (expansion = @unions[source])
|
|
39
|
+
return source.start_with?("Keiyaku::OneOf[") ? expand_union(expansion) : Keiyaku.snake(source)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
SCALARS[source] || source
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def source(order, operations)
|
|
46
|
+
models = order.map { model_rbs(_1) }
|
|
47
|
+
methods = operations.filter_map { method_rbs(_1) }
|
|
48
|
+
|
|
49
|
+
<<~RBS
|
|
50
|
+
#{HEADER}
|
|
51
|
+
|
|
52
|
+
module #{@namespace}
|
|
53
|
+
#{models.join("\n\n")}
|
|
54
|
+
|
|
55
|
+
class Client < Keiyaku::Client
|
|
56
|
+
#{methods.join("\n")}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
RBS
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# A union is written bare everywhere but one place: RBS reads `|` after a
|
|
65
|
+
# return type as the start of another overload, so `-> A | B` is a syntax
|
|
66
|
+
# error where `attr_reader a: A | B` is fine. Only a top-level `|` needs
|
|
67
|
+
# the parentheses — inside `Array[...]` the brackets already close it off.
|
|
68
|
+
def return_type(type)
|
|
69
|
+
depth = 0
|
|
70
|
+
type.each_char do |char|
|
|
71
|
+
case char
|
|
72
|
+
when "[" then depth += 1
|
|
73
|
+
when "]" then depth -= 1
|
|
74
|
+
when "|" then return "(#{type})" if depth.zero?
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
type
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# How the operation's `into:` reads as a return type. `untyped` in a union
|
|
81
|
+
# says nothing the union did not already say and takes the rest of it down
|
|
82
|
+
# with it, so an operation with one response the document left open is
|
|
83
|
+
# that: untyped.
|
|
84
|
+
def into_type(into)
|
|
85
|
+
types = into.values.uniq.map { type_for(_1) }
|
|
86
|
+
types.include?("untyped") ? "untyped" : types.join(" | ")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# RBS has no constant that stands for a union, so a union component becomes
|
|
90
|
+
# a type alias for signatures to use, plus the constant itself for callers
|
|
91
|
+
# that reach for `Event.cast`.
|
|
92
|
+
def union_type(const)
|
|
93
|
+
source = @models[const]
|
|
94
|
+
constant =
|
|
95
|
+
if source.start_with?("Keiyaku::OneOf[") then "Keiyaku::OneOf"
|
|
96
|
+
elsif source == ":any" then "Symbol"
|
|
97
|
+
else "untyped" # a union that collapsed to one type, so the constant is that type
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
" type #{Keiyaku.snake(const)} = #{expand_union(@unions[const])}\n #{const}: #{constant}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A variant of a union may be a component that turned out not to be a
|
|
104
|
+
# class — an array, a scalar — and RBS refers to one of those by its type
|
|
105
|
+
# alias. The constant is still there, but it holds a value rather than
|
|
106
|
+
# naming a type, so a signature that used it would not resolve.
|
|
107
|
+
def expand_union(expansion)
|
|
108
|
+
expansion.split(" | ").map { |name| @models[name].is_a?(String) ? Keiyaku.snake(name) : name }.join(" | ")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def model_rbs(const)
|
|
112
|
+
model = @models[const]
|
|
113
|
+
return union_type(const) if model.is_a?(String)
|
|
114
|
+
|
|
115
|
+
# `attr_reader "+1":` is not RBS, in either spelling — the name is not
|
|
116
|
+
# a method, so it is typed where it is actually read instead.
|
|
117
|
+
bare, quoted = model.fields.partition { |field, _| Names.bare?(field) }
|
|
118
|
+
declared = lambda do |field, type|
|
|
119
|
+
"#{type_for(type)}#{"?" unless model.required.include?(field)}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
readers = bare.map { |field, type| " attr_reader #{field}: #{declared.(field, type)}" }
|
|
123
|
+
|
|
124
|
+
# Only the names that cannot be an attr_reader are worth an overload:
|
|
125
|
+
# `[]` is already declared on the base class, and a subclass's `def`
|
|
126
|
+
# replaces that rather than adding to it, so anything left out here is
|
|
127
|
+
# left out of the type entirely. Hence the trailing overload — without
|
|
128
|
+
# it, typing `rollup["+1"]` would cost `rollup[:total]`, which the same
|
|
129
|
+
# method reads perfectly well.
|
|
130
|
+
unless quoted.empty?
|
|
131
|
+
overloads = quoted.map { |field, type| "(#{field.inspect}) -> #{declared.(field, type)}" }
|
|
132
|
+
readers << " def []: #{(overloads + ["(String | Symbol) -> untyped"]).join("\n | ")}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# `to_json_hash` and `deconstruct_keys` are not emitted: the first is
|
|
136
|
+
# word for word what the base class declares, and the second would be
|
|
137
|
+
# a union of every type in the model, which a pattern match binding one
|
|
138
|
+
# field is not helped by. What is emitted is what is more precise here
|
|
139
|
+
# than it can be there.
|
|
140
|
+
<<~RBS.chomp
|
|
141
|
+
class #{const} < ::Keiyaku::Model
|
|
142
|
+
#{readers.join("\n")}
|
|
143
|
+
def self.cast: (untyped, ?String) -> #{const}
|
|
144
|
+
#{constructors(const, model)}
|
|
145
|
+
end
|
|
146
|
+
RBS
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# `new` and `with`, spelled out. Inherited from Keiyaku::Model they are
|
|
150
|
+
# `(**untyped)`, which typechecks a call site that leaves out a required
|
|
151
|
+
# field and one that misspells an optional one — the two mistakes a
|
|
152
|
+
# signature for a request body exists to catch.
|
|
153
|
+
#
|
|
154
|
+
# An open model still takes anything beyond its fields, so **untyped is
|
|
155
|
+
# the truth there and the declared keywords are what is gained.
|
|
156
|
+
def constructors(const, model)
|
|
157
|
+
keywords = model.fields.filter_map do |field, type|
|
|
158
|
+
next unless Names.bare?(field)
|
|
159
|
+
|
|
160
|
+
[field, "#{type_for(type)}#{"?" unless model.required.include?(field)}"]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
required, optional = keywords.partition { |field, _| model.required.include?(field) }
|
|
164
|
+
new_args = required.map { |field, type| "#{field}: #{type}" } +
|
|
165
|
+
optional.map { |field, type| "?#{field}: #{type}" }
|
|
166
|
+
with_args = keywords.map { |field, type| "?#{field}: #{type}" }
|
|
167
|
+
|
|
168
|
+
# A field Ruby will not take as a keyword label cannot be declared as
|
|
169
|
+
# one, so a model with any is as open as one the document opened.
|
|
170
|
+
rest = model.open || keywords.size < model.fields.size ? ["**untyped"] : []
|
|
171
|
+
|
|
172
|
+
[" def self.new: (#{(new_args + rest).join(", ")}) -> #{const}",
|
|
173
|
+
" def with: (#{(with_args + rest).join(", ")}) -> #{const}"].join("\n")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def method_rbs(op)
|
|
177
|
+
return if op[:name].nil?
|
|
178
|
+
if op[:unsupported]
|
|
179
|
+
return " def #{op[:name]}: (*untyped) -> bot # not generated: #{Emitter.comment(op[:unsupported])}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
types = op[:types]
|
|
183
|
+
positional = op[:template].scan(/\{(\w+)\}/).flatten.map do |param|
|
|
184
|
+
"#{type_for(types[param] || ":any")} #{Keiyaku.snake(param)}"
|
|
185
|
+
end
|
|
186
|
+
payload = op[:body] || op[:form] || op[:multipart]
|
|
187
|
+
positional << "#{"?" unless op[:body_required]}#{type_for(payload)} body" if payload
|
|
188
|
+
|
|
189
|
+
keyword = lambda do |json_name, ruby_name|
|
|
190
|
+
required = op[:required].include?(json_name)
|
|
191
|
+
type = type_for(types[json_name] || ":any")
|
|
192
|
+
"#{"?" unless required}#{Keiyaku.snake(ruby_name)}: #{type}#{"?" unless required}"
|
|
193
|
+
end
|
|
194
|
+
keywords = op[:query].map { keyword.(_1, _1) } +
|
|
195
|
+
op[:header].map { |json, ruby| keyword.(json, ruby) }
|
|
196
|
+
arguments = (positional + keywords).join(", ")
|
|
197
|
+
" def #{op[:name]}: (#{arguments}) -> #{op[:into] ? return_type(into_type(op[:into])) : "untyped"}"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|