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.
data/README.md ADDED
@@ -0,0 +1,174 @@
1
+ # keiyaku
2
+
3
+ [![ci](https://github.com/onyxblade/keiyaku/actions/workflows/ci.yml/badge.svg)](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