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
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "time"
|
|
6
|
+
require "date"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require "socket"
|
|
9
|
+
require "timeout"
|
|
10
|
+
require_relative "version"
|
|
11
|
+
|
|
12
|
+
# A runtime for generated OpenAPI clients.
|
|
13
|
+
#
|
|
14
|
+
# Everything that is identical across every API lives here; the generated code
|
|
15
|
+
# carries only what is specific to one API. The contract between the two is a
|
|
16
|
+
# single method, Client#__invoke, plus Keiyaku.model.
|
|
17
|
+
module Keiyaku
|
|
18
|
+
UNSET = Object.new
|
|
19
|
+
def UNSET.inspect = "Keiyaku::UNSET"
|
|
20
|
+
UNSET.freeze
|
|
21
|
+
|
|
22
|
+
class Error < StandardError; end
|
|
23
|
+
class CastError < Error; end
|
|
24
|
+
|
|
25
|
+
# Raised by operations the generator refused to emit. The message names the
|
|
26
|
+
# construct it could not support, so a wrong client is never silently built.
|
|
27
|
+
class Unsupported < Error; end
|
|
28
|
+
|
|
29
|
+
class HTTPError < Error
|
|
30
|
+
attr_reader :status, :headers, :body, :parsed
|
|
31
|
+
|
|
32
|
+
def initialize(status:, headers:, body:, parsed: nil)
|
|
33
|
+
@status = status
|
|
34
|
+
@headers = headers
|
|
35
|
+
@body = body
|
|
36
|
+
@parsed = parsed
|
|
37
|
+
super("HTTP #{status}#{": #{parsed.inspect}" if parsed}")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class ClientError < HTTPError; end
|
|
42
|
+
class ServerError < HTTPError; end
|
|
43
|
+
|
|
44
|
+
# The request did not happen: connection refused, DNS failure, a timeout.
|
|
45
|
+
# Every adapter raises this rather than its own library's class, because
|
|
46
|
+
# otherwise the seam leaks — an application that moves a client from
|
|
47
|
+
# Net::HTTP to Faraday would find its `rescue` quietly matching nothing, and
|
|
48
|
+
# a call it thought it had covered taking the process down. The original is
|
|
49
|
+
# on #cause for anything that does want to know.
|
|
50
|
+
class ConnectionError < Error; end
|
|
51
|
+
|
|
52
|
+
# What a transport failure looks like from the stdlib, which an adapter an
|
|
53
|
+
# application wrote itself is likely to let through as-is. Net::OpenTimeout
|
|
54
|
+
# and Net::ReadTimeout are Timeout::Error; Errno::ECONNREFUSED and the rest
|
|
55
|
+
# of Errno are SystemCallError. socket and timeout are required above for
|
|
56
|
+
# these two names, which is all the runtime itself wants from a transport.
|
|
57
|
+
TRANSPORT_ERRORS = [IOError, SystemCallError, SocketError, Timeout::Error].freeze
|
|
58
|
+
|
|
59
|
+
# The words Ruby will not read as a name. Lives here rather than with the
|
|
60
|
+
# generator's other name tables because the generated method body has to
|
|
61
|
+
# know too: a parameter may be named for one of these, and reading it is
|
|
62
|
+
# not a matter of writing it down.
|
|
63
|
+
KEYWORDS = %w[
|
|
64
|
+
BEGIN END alias and begin break case class def defined do else elsif end ensure false for if in module
|
|
65
|
+
next nil not or redo rescue retry return self super then true undef unless until when while yield __FILE__
|
|
66
|
+
__LINE__ __ENCODING__
|
|
67
|
+
].freeze
|
|
68
|
+
|
|
69
|
+
module_function
|
|
70
|
+
|
|
71
|
+
def camelize(name)
|
|
72
|
+
head, *rest = name.to_s.split("_")
|
|
73
|
+
head + rest.map(&:capitalize).join
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def snake(name)
|
|
77
|
+
name.to_s
|
|
78
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
79
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
80
|
+
.tr("- ", "__")
|
|
81
|
+
.gsub(/[^a-zA-Z0-9_]/, "")
|
|
82
|
+
.downcase
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# `timeout:` as every adapter takes it: one number for both phases, or
|
|
86
|
+
# `{ open: 2, read: 10 }` for two. They are usually two different patiences
|
|
87
|
+
# — how long to wait to find out a host is not there is not how long to wait
|
|
88
|
+
# for a slow answer — and a client sitting inside somebody else's request
|
|
89
|
+
# has to bound the first one much more tightly than the second.
|
|
90
|
+
def timeouts(timeout)
|
|
91
|
+
return [timeout, timeout] unless timeout.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
unknown = timeout.keys - %i[open read]
|
|
94
|
+
raise ArgumentError, "timeout: takes open: and read:, not #{unknown.map(&:inspect).join(", ")}" if unknown.any?
|
|
95
|
+
|
|
96
|
+
[timeout[:open], timeout[:read]]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Coerce a decoded JSON value into a declared type.
|
|
100
|
+
#
|
|
101
|
+
# Types are written the way they read: Integer, String, :bool, Time,
|
|
102
|
+
# [Pet] for an array, { String => Pet } for a map, :any to pass through.
|
|
103
|
+
def coerce(type, value, path)
|
|
104
|
+
return nil if value.nil?
|
|
105
|
+
|
|
106
|
+
case type
|
|
107
|
+
when Array then Array(value).each_with_index.map { |v, i| coerce(type.first, v, "#{path}[#{i}]") }
|
|
108
|
+
when Hash
|
|
109
|
+
# A map's keys are the document's own, whatever they turn out to be, and
|
|
110
|
+
# only the values have a declared type. Something that is not an object
|
|
111
|
+
# cannot be read as one, and saying so here is what keeps a bare `to_h`
|
|
112
|
+
# from raising a NoMethodError with none of the path in it.
|
|
113
|
+
raise CastError, "#{path}: expected an object, got #{value.class}" unless value.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
value.to_h { |k, v| [k, coerce(type.values.first, v, "#{path}.#{k}")] }
|
|
116
|
+
when :any then value
|
|
117
|
+
when :bool then !!value
|
|
118
|
+
when Symbol then value
|
|
119
|
+
when Proc then coerce(type.call, value, path) # breaks reference cycles
|
|
120
|
+
else
|
|
121
|
+
if type.respond_to?(:cast) then type.cast(value, path)
|
|
122
|
+
elsif type == String then value.is_a?(String) ? value : value.to_s
|
|
123
|
+
elsif type == Integer then Integer(value)
|
|
124
|
+
elsif type == Float then Float(value)
|
|
125
|
+
elsif type == Time then value.is_a?(Time) ? value : Time.parse(value.to_s)
|
|
126
|
+
elsif type == Date then value.is_a?(Date) ? value : Date.parse(value.to_s)
|
|
127
|
+
else raise CastError, "#{path}: don't know how to cast to #{type.inspect}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
rescue ArgumentError, TypeError => e
|
|
131
|
+
raise CastError, "#{path}: #{e.message} (got #{value.inspect})"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Inverse of #coerce: a Ruby value on its way into a request body.
|
|
135
|
+
def dump(value)
|
|
136
|
+
case value
|
|
137
|
+
when Time then value.iso8601
|
|
138
|
+
when Date then value.iso8601
|
|
139
|
+
when Array then value.map { dump(_1) }
|
|
140
|
+
when Hash then value.to_h { |k, v| [k.to_s, dump(v)] }
|
|
141
|
+
else value.respond_to?(:to_json_hash) ? value.to_json_hash : value
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# What a table of responses says about one status. A document keys an entry
|
|
146
|
+
# by the code itself, by one of the ranges it is allowed to write in place of
|
|
147
|
+
# one — "4XX" for every client error — or by `default` for whatever it did
|
|
148
|
+
# not describe. The narrowest of them answers: a 404 is answered by its own
|
|
149
|
+
# entry before the range's, and by the range's before the catch-all, which is
|
|
150
|
+
# the order the document wrote them in for.
|
|
151
|
+
def for_status(table, status)
|
|
152
|
+
table[status] || table["#{status / 100}XX"] || table[:default]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Build a value type for one schema.
|
|
156
|
+
#
|
|
157
|
+
# Pet = Keiyaku.model({ id: Integer, name: String }, required: %i[name])
|
|
158
|
+
#
|
|
159
|
+
# Returns a Keiyaku::Model subclass, so callers get immutability, #with and
|
|
160
|
+
# pattern matching. Missing optional fields arrive as nil rather than
|
|
161
|
+
# raising, and unknown fields in a response are ignored so that a server
|
|
162
|
+
# adding a field does not break an old client.
|
|
163
|
+
#
|
|
164
|
+
# The fields are a positional Hash rather than keywords because they are the
|
|
165
|
+
# API's names, not ours: a DIDComm message has a property called `from`, and
|
|
166
|
+
# as keywords it would have quietly taken the place of the option below it.
|
|
167
|
+
#
|
|
168
|
+
# `open:` is what the document's `additionalProperties` said: false for a
|
|
169
|
+
# schema that named all its properties, true for one that allows any others,
|
|
170
|
+
# or the type it declared for their values.
|
|
171
|
+
def model(fields, required: [], from: {}, open: false)
|
|
172
|
+
Class.new(Model) { __define(fields, required:, from:, open:) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The value type a schema becomes: frozen, compared by value, copied with
|
|
176
|
+
# #with, matched with `in`. One subclass per schema, built by Keiyaku.model,
|
|
177
|
+
# with a matching RBS class emitted beside the generated code.
|
|
178
|
+
#
|
|
179
|
+
# This was a Data subclass until `additionalProperties` needed somewhere to
|
|
180
|
+
# put the properties a document permits but does not name. A Data's members
|
|
181
|
+
# are the whole of its state, so an overflow could only have been one more
|
|
182
|
+
# member — turning up in #members, #to_h and every pattern match, which is
|
|
183
|
+
# precisely what those keys are not. Written out, the overflow is an
|
|
184
|
+
# ordinary ivar and the shape a model presents stays the schema's.
|
|
185
|
+
class Model
|
|
186
|
+
class << self
|
|
187
|
+
# The schema as the generator read it: field name => type, in the
|
|
188
|
+
# document's order. `members` is the same list without the types.
|
|
189
|
+
attr_reader :members, :types
|
|
190
|
+
|
|
191
|
+
# Field name => the name it goes by on the wire.
|
|
192
|
+
attr_reader :json_names
|
|
193
|
+
|
|
194
|
+
attr_reader :required
|
|
195
|
+
|
|
196
|
+
# What `additionalProperties` said: false, true, or a type for the
|
|
197
|
+
# values. Reading it is how #cast and #[] know which they are dealing
|
|
198
|
+
# with; `open?` is the question almost everything actually asks.
|
|
199
|
+
attr_reader :additional
|
|
200
|
+
|
|
201
|
+
def open? = !!@additional
|
|
202
|
+
|
|
203
|
+
def __define(fields, required:, from:, open:)
|
|
204
|
+
@types = fields.freeze
|
|
205
|
+
@members = fields.keys.freeze
|
|
206
|
+
@json_names = @members.to_h { |field| [field, (from[field] || Keiyaku.camelize(field)).to_s] }.freeze
|
|
207
|
+
@required = required.freeze
|
|
208
|
+
@additional = open
|
|
209
|
+
|
|
210
|
+
# define_method takes a name Ruby will not parse as one, and
|
|
211
|
+
# public_send reads it back; only the dot is out of reach.
|
|
212
|
+
@members.each { |field| define_method(field) { @attributes[field] } }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def cast(value, path = (name || "value"))
|
|
216
|
+
return value if value.is_a?(self)
|
|
217
|
+
raise CastError, "#{path}: expected an object, got #{value.class}" unless value.is_a?(Hash)
|
|
218
|
+
|
|
219
|
+
attrs = types.to_h do |field, type|
|
|
220
|
+
json = json_names[field]
|
|
221
|
+
raw = value.key?(json) ? value[json] : value[field.to_s]
|
|
222
|
+
if raw.nil? && required.include?(field) && !value.key?(json)
|
|
223
|
+
raise CastError, "#{path}: missing required field #{json.inspect}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
[field, Keiyaku.coerce(type, raw, "#{path}.#{field}")]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
return new(**attrs) unless open?
|
|
230
|
+
|
|
231
|
+
# Everything the schema did not name, under the spelling it arrived
|
|
232
|
+
# with: a declared field has `from:` to translate its name and an
|
|
233
|
+
# undeclared one has nothing, so camelizing it would be a guess. That
|
|
234
|
+
# is also what makes the round trip lossless.
|
|
235
|
+
extra = value.except(*json_names.values, *members.map(&:to_s)).to_h do |key, raw|
|
|
236
|
+
[key.to_sym, additional == true ? raw : Keiyaku.coerce(additional, raw, "#{path}.#{key}")]
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
new(**attrs, **extra)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Lenient about what is missing, strict about what it does not know.
|
|
244
|
+
# A field left out is nil, because a schema with thirty optional
|
|
245
|
+
# properties is not worth thirty keywords at every call site; a field that
|
|
246
|
+
# is not in the schema is a typo, and the alternative to saying so is a
|
|
247
|
+
# request that quietly goes out without it. On an open model there is no
|
|
248
|
+
# such thing as a keyword the schema did not mention, so it is kept.
|
|
249
|
+
def initialize(**kw)
|
|
250
|
+
members = self.class.members
|
|
251
|
+
unknown = kw.keys - members
|
|
252
|
+
if unknown.any? && !self.class.open?
|
|
253
|
+
raise ArgumentError, "unknown keyword#{"s" if unknown.size > 1}: #{unknown.map(&:inspect).join(", ")}"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
@attributes = members.to_h { |field| [field, kw[field]] }.freeze
|
|
257
|
+
@extra = unknown.to_h { |key| [key.to_s, kw[key]] }.freeze
|
|
258
|
+
freeze
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# How a field is read when its name is not one Ruby will take through a
|
|
262
|
+
# dot: GitHub counts thumbs-up reactions in a property called `+1`, and
|
|
263
|
+
# renaming it here would be inventing a name the document never used.
|
|
264
|
+
# Ordinary fields answer to it too, so nothing has to know which is which.
|
|
265
|
+
#
|
|
266
|
+
# On a closed model a name it does not have is a typo rather than a nil.
|
|
267
|
+
# An open model was told there would be names it does not have, so the
|
|
268
|
+
# same premise says the opposite there: this is where the overflow is
|
|
269
|
+
# read, and a miss is nil the way it is in a Hash.
|
|
270
|
+
def [](name)
|
|
271
|
+
field = name.to_sym
|
|
272
|
+
return @attributes[field] if @attributes.key?(field)
|
|
273
|
+
return @extra[name.to_s] if self.class.open?
|
|
274
|
+
|
|
275
|
+
raise ArgumentError, "#{self.class} has no field #{name.inspect}"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def with(**kw) = self.class.new(**@attributes, **@extra.transform_keys(&:to_sym), **kw)
|
|
279
|
+
|
|
280
|
+
def to_h(&block) = block ? @attributes.to_h(&block) : @attributes.dup
|
|
281
|
+
|
|
282
|
+
def deconstruct = @attributes.values
|
|
283
|
+
|
|
284
|
+
def deconstruct_keys(keys)
|
|
285
|
+
return @attributes.dup if keys.nil?
|
|
286
|
+
|
|
287
|
+
keys.each_with_object({}) { |key, found| found[key] = @attributes[key] if @attributes.key?(key) }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Read through the ivars rather than a reader of this class's own: a
|
|
291
|
+
# property may be named anything at all, and a document with one named for
|
|
292
|
+
# that reader would quietly break equality instead of merely shadowing a
|
|
293
|
+
# method nothing calls.
|
|
294
|
+
def ==(other)
|
|
295
|
+
return false unless other.instance_of?(self.class)
|
|
296
|
+
|
|
297
|
+
@attributes == other.instance_variable_get(:@attributes) &&
|
|
298
|
+
@extra == other.instance_variable_get(:@extra)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def eql?(other)
|
|
302
|
+
return false unless other.instance_of?(self.class)
|
|
303
|
+
|
|
304
|
+
@attributes.eql?(other.instance_variable_get(:@attributes)) &&
|
|
305
|
+
@extra.eql?(other.instance_variable_get(:@extra))
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def hash = [self.class, @attributes, @extra].hash
|
|
309
|
+
|
|
310
|
+
def inspect
|
|
311
|
+
shown = @attributes.map { |field, value| "#{field}=#{value.inspect}" }
|
|
312
|
+
# The overflow prints as the bag it is, so a key the schema never named
|
|
313
|
+
# does not read as one it did — and prints only when there is something
|
|
314
|
+
# in it. It has no reader of its own, so leaving it out entirely would
|
|
315
|
+
# make it visible only to someone who already knew the key to ask for.
|
|
316
|
+
shown << @extra.inspect unless @extra.empty?
|
|
317
|
+
"#<#{self.class.name || "Keiyaku::Model"}#{" " unless shown.empty?}#{shown.join(", ")}>"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
alias to_s inspect
|
|
321
|
+
|
|
322
|
+
def to_json_hash
|
|
323
|
+
named = self.class.json_names.filter_map do |field, json|
|
|
324
|
+
value = @attributes[field]
|
|
325
|
+
[json, Keiyaku.dump(value)] unless value.nil?
|
|
326
|
+
end.to_h
|
|
327
|
+
return named if @extra.empty?
|
|
328
|
+
|
|
329
|
+
named.merge(@extra.transform_values { Keiyaku.dump(_1) })
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def to_json(*args) = to_json_hash.to_json(*args)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# A union with a discriminator: into: OneOf[Dog, Cat, on: "petType"]
|
|
336
|
+
class OneOf
|
|
337
|
+
def self.[](*variants, on:, map: {}) = new(variants, on, map)
|
|
338
|
+
|
|
339
|
+
def initialize(variants, discriminator, map)
|
|
340
|
+
@variants = variants
|
|
341
|
+
@discriminator = discriminator
|
|
342
|
+
@map = map
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def cast(value, path = "value")
|
|
346
|
+
raise CastError, "#{path}: expected an object" unless value.is_a?(Hash)
|
|
347
|
+
|
|
348
|
+
tag = value[@discriminator]
|
|
349
|
+
variant = @map[tag] || @variants.find { |v| Keiyaku.snake(v.name.to_s.split("::").last) == Keiyaku.snake(tag.to_s) }
|
|
350
|
+
raise CastError, "#{path}: no variant for #{@discriminator}=#{tag.inspect}" unless variant
|
|
351
|
+
|
|
352
|
+
variant.cast(value, path)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Several success responses that are several types:
|
|
357
|
+
#
|
|
358
|
+
# into: ByStatus[200 => PagesHealthCheck, 202 => EmptyObject]
|
|
359
|
+
#
|
|
360
|
+
# Not a type — nothing casts *into* one of these — but the thing an
|
|
361
|
+
# operation's `into:` is when the document gave more than one answer. The
|
|
362
|
+
# document says which type belongs to which status and the response carries
|
|
363
|
+
# the status, so the choice is read rather than guessed. A status the
|
|
364
|
+
# document did not describe is left alone, since the alternative is a
|
|
365
|
+
# CastError naming a type the server never claimed to be sending.
|
|
366
|
+
class ByStatus
|
|
367
|
+
def self.[](types) = new(types)
|
|
368
|
+
|
|
369
|
+
attr_reader :types
|
|
370
|
+
|
|
371
|
+
def initialize(types)
|
|
372
|
+
@types = types.to_h { |status, type| [normalise(status), type] }.freeze
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def [](status) = Keiyaku.for_status(@types, status)
|
|
376
|
+
|
|
377
|
+
private
|
|
378
|
+
|
|
379
|
+
# A code is the number it is however it was written, and a range is the
|
|
380
|
+
# string it was written as, in the case the specification writes it in.
|
|
381
|
+
def normalise(status)
|
|
382
|
+
return status.to_i if status.to_s.match?(/\A\d+\z/)
|
|
383
|
+
|
|
384
|
+
status.is_a?(String) ? status.upcase : status
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# One file in a multipart/form-data body.
|
|
389
|
+
#
|
|
390
|
+
# Keiyaku::Upload.new(File.open("kaya.png"))
|
|
391
|
+
# Keiyaku::Upload.new(bytes, filename: "kaya.png", content_type: "image/png")
|
|
392
|
+
#
|
|
393
|
+
# An IO passed on its own is wrapped in one of these, so the common case
|
|
394
|
+
# needs no ceremony. The content type is not guessed from the extension —
|
|
395
|
+
# a wrong one is worse than the honest default.
|
|
396
|
+
class Upload
|
|
397
|
+
attr_reader :io, :filename, :content_type
|
|
398
|
+
|
|
399
|
+
def initialize(io, filename: nil, content_type: nil)
|
|
400
|
+
@io = io
|
|
401
|
+
@filename = filename || (io.respond_to?(:path) ? File.basename(io.path) : "file")
|
|
402
|
+
@content_type = content_type || "application/octet-stream"
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def read = @io.respond_to?(:read) ? @io.read : @io.to_s
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Serialize parameters per the OpenAPI style/explode rules. Implemented are
|
|
409
|
+
# the defaults — `form` for query, `simple` for path and header — and the
|
|
410
|
+
# one rendering `deepObject` has. The generator refuses anything else rather
|
|
411
|
+
# than guessing, so a name reaching `deep:` here has already been checked.
|
|
412
|
+
module Serialize
|
|
413
|
+
module_function
|
|
414
|
+
|
|
415
|
+
def query(params, deep: [])
|
|
416
|
+
params.flat_map do |name, value|
|
|
417
|
+
next deep_object(name, value) if deep.include?(name) && !value.nil?
|
|
418
|
+
|
|
419
|
+
case value
|
|
420
|
+
when nil then []
|
|
421
|
+
when Array then value.map { |v| [name, stringify(v)] }
|
|
422
|
+
when Hash then value.map { |k, v| [k.to_s, stringify(v)] }
|
|
423
|
+
else [[name, stringify(value)]]
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# filter[status]=live&filter[since]=2026-07-27, which is the whole of what
|
|
429
|
+
# `deepObject` means. The specification stops at one level — it says
|
|
430
|
+
# nothing about what a key's own value may be — so a value that is itself
|
|
431
|
+
# an object or an array is refused rather than sent as whatever #to_s
|
|
432
|
+
# makes of it, which no server could read back.
|
|
433
|
+
def deep_object(name, value)
|
|
434
|
+
fields = Keiyaku.dump(value)
|
|
435
|
+
raise Error, "#{name} is #{value.class}, and a deepObject parameter is an object" unless fields.is_a?(Hash)
|
|
436
|
+
|
|
437
|
+
fields.filter_map do |key, inner|
|
|
438
|
+
next if inner.nil?
|
|
439
|
+
|
|
440
|
+
if inner.is_a?(Hash) || inner.is_a?(Array)
|
|
441
|
+
raise Error, "#{name}[#{key}] is #{inner.class}; OpenAPI does not say how deepObject nests"
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
["#{name}[#{key}]", stringify(inner)]
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# What a path parameter has to be encoded down to: RFC 3986's unreserved
|
|
449
|
+
# characters are what simple expansion may leave as they are, and this
|
|
450
|
+
# matches everything else.
|
|
451
|
+
ESCAPED = /[^A-Za-z0-9\-._~]/
|
|
452
|
+
|
|
453
|
+
def path(name, value, explode: false) = simple(name, value, explode:, escape: true)
|
|
454
|
+
|
|
455
|
+
# OpenAPI's `simple` style, which is what a path or a header parameter is
|
|
456
|
+
# written in unless the document said otherwise: an array is its elements
|
|
457
|
+
# separated by commas, and an object is its keys and values in that same
|
|
458
|
+
# flat list — or `key=value` pairs where `explode` said so, which is the
|
|
459
|
+
# one part of this a value cannot be asked and the operation has to carry.
|
|
460
|
+
#
|
|
461
|
+
# What it exists to keep off the wire is Ruby's own #to_s: `[1, 2]` on a
|
|
462
|
+
# header reaches a server as a string with brackets and a space in it, and
|
|
463
|
+
# nothing on the other side reads that back as two values.
|
|
464
|
+
def simple(name, value, explode: false, escape: false)
|
|
465
|
+
part = ->(inner) { simple_part(name, inner, escape:) }
|
|
466
|
+
|
|
467
|
+
case (value = Keiyaku.dump(value))
|
|
468
|
+
when Array then value.map(&part).join(",")
|
|
469
|
+
when Hash then value.map { |key, inner| "#{part.(key)}#{explode ? "=" : ","}#{part.(inner)}" }.join(",")
|
|
470
|
+
else part.(value)
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# One value inside a simple parameter. The style's row in the
|
|
475
|
+
# specification stops where deepObject's does: it gives no spelling for an
|
|
476
|
+
# array or an object inside one, so that is refused rather than sent as
|
|
477
|
+
# whatever #to_s makes of it.
|
|
478
|
+
#
|
|
479
|
+
# In a path it is percent-encoded and the separators around it are not,
|
|
480
|
+
# which is RFC 6570's simple expansion and the only way the two are told
|
|
481
|
+
# apart: a segment is allowed to hold a comma, so the comma between two
|
|
482
|
+
# elements is left as one and a comma inside an element becomes %2C. The
|
|
483
|
+
# encoding is down to the unreserved characters — a space is %20 and not
|
|
484
|
+
# the `+` that means a space only in a query — and it is by byte, so a
|
|
485
|
+
# name outside ASCII survives the trip. A header is not a URL and is not
|
|
486
|
+
# encoded at all.
|
|
487
|
+
def simple_part(name, value, escape: false)
|
|
488
|
+
if value.is_a?(Array) || value.is_a?(Hash)
|
|
489
|
+
raise Error, "#{name} contains #{value.class}; OpenAPI does not say how a simple parameter nests"
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
part = stringify(value)
|
|
493
|
+
escape ? part.gsub(ESCAPED) { |char| char.each_byte.map { format("%%%02X", _1) }.join } : part
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Build a multipart/form-data body. An array property becomes one part per
|
|
497
|
+
# element, which is what the default `form` encoding means for an array.
|
|
498
|
+
def multipart(fields, boundary)
|
|
499
|
+
parts = fields.flat_map do |name, value|
|
|
500
|
+
(value.is_a?(Array) ? value : [value]).map { part(name, _1, boundary) }
|
|
501
|
+
end
|
|
502
|
+
parts.join.b << "--#{boundary}--\r\n".b
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def part(name, value, boundary)
|
|
506
|
+
value = Upload.new(value) if value.respond_to?(:read) && !value.is_a?(Upload)
|
|
507
|
+
disposition = %(Content-Disposition: form-data; name="#{name}")
|
|
508
|
+
|
|
509
|
+
headers, payload =
|
|
510
|
+
case value
|
|
511
|
+
when Upload
|
|
512
|
+
[%(#{disposition}; filename="#{value.filename}"\r\nContent-Type: #{value.content_type}\r\n), value.read]
|
|
513
|
+
when Hash, Array
|
|
514
|
+
# What OpenAPI's encoding rules default to for a non-primitive part.
|
|
515
|
+
["#{disposition}\r\nContent-Type: application/json\r\n", JSON.generate(value)]
|
|
516
|
+
else
|
|
517
|
+
["#{disposition}\r\n", stringify(value)]
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
"--#{boundary}\r\n#{headers}\r\n".b << payload.to_s.b << "\r\n".b
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def stringify(value)
|
|
524
|
+
case value
|
|
525
|
+
when Time then value.iso8601
|
|
526
|
+
when Date then value.iso8601
|
|
527
|
+
else value.to_s
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# Transport. An adapter is any object with
|
|
533
|
+
#
|
|
534
|
+
# call(verb, uri, headers, body) -> [status, headers, body]
|
|
535
|
+
#
|
|
536
|
+
# where verb is a lower-case Symbol, headers going out is a String => String
|
|
537
|
+
# Hash, and body is a String or nil. Response header names may come back in
|
|
538
|
+
# whatever case the underlying library uses; the client lower-cases them
|
|
539
|
+
# before looking anything up, so an adapter cannot get that wrong.
|
|
540
|
+
#
|
|
541
|
+
# This is the only part of the runtime a host application might want to
|
|
542
|
+
# replace, which is why it is one method with no state.
|
|
543
|
+
#
|
|
544
|
+
# Every adapter is a file of its own, this one included: the runtime says
|
|
545
|
+
# what the seam is and holds none of it. The stdlib one is autoloaded rather
|
|
546
|
+
# than opted into like faraday's and http.rb's, because it is the default a
|
|
547
|
+
# client builds when it was given no adapter — an application that named its
|
|
548
|
+
# own should not have to load net/http to find that out.
|
|
549
|
+
autoload :NetHTTPAdapter, File.expand_path("adapters/net_http", __dir__)
|
|
550
|
+
|
|
551
|
+
# Base class for generated clients. The generated subclass contains one
|
|
552
|
+
# declaration per operation and nothing else.
|
|
553
|
+
class Client
|
|
554
|
+
class << self
|
|
555
|
+
attr_reader :operations
|
|
556
|
+
|
|
557
|
+
def inherited(subclass)
|
|
558
|
+
super
|
|
559
|
+
subclass.instance_variable_set(:@operations, operations&.dup || {})
|
|
560
|
+
subclass.instance_variable_set(:@server, @server)
|
|
561
|
+
subclass.instance_variable_set(:@security, @security)
|
|
562
|
+
subclass.instance_variable_set(:@default_security, @default_security)
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def server(url = nil) = url ? @server = url : @server
|
|
566
|
+
|
|
567
|
+
# The document's security schemes, by the name it gave them, plus the
|
|
568
|
+
# requirement that holds for an operation which does not state its own.
|
|
569
|
+
#
|
|
570
|
+
# security({ api_key: { header: "api_key" }, petstore_auth: :bearer },
|
|
571
|
+
# default: :api_key)
|
|
572
|
+
#
|
|
573
|
+
# A scheme is `:bearer`, `:basic`, or one of `{ header: }`, `{ query: }`,
|
|
574
|
+
# `{ cookie: }` naming where an API key goes. Credentials are then given
|
|
575
|
+
# by scheme name, because a document that declares two has no single
|
|
576
|
+
# "the" credential and picking one for the caller is how a client ends
|
|
577
|
+
# up sending the wrong header to every operation it has.
|
|
578
|
+
def security(schemes = nil, default: nil)
|
|
579
|
+
return @security || {} if schemes.nil?
|
|
580
|
+
|
|
581
|
+
@security = schemes.to_h { |name, scheme| [name.to_sym, scheme] }
|
|
582
|
+
@default_security = requirement(default)
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def default_security = @default_security || []
|
|
586
|
+
|
|
587
|
+
# A security requirement is a list of alternatives, each of which is a
|
|
588
|
+
# set of schemes that all have to be satisfied: OpenAPI's OR of ANDs.
|
|
589
|
+
# `false` and `[]` are the operation that takes no credentials at all,
|
|
590
|
+
# which is not the same as one that does not say.
|
|
591
|
+
def requirement(declared)
|
|
592
|
+
case declared
|
|
593
|
+
when nil, false then []
|
|
594
|
+
when Symbol then [[declared]]
|
|
595
|
+
else declared.map { |alternative| Array(alternative).map(&:to_sym) }
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
%i[get post put patch delete head options].each do |verb|
|
|
600
|
+
define_method(verb) do |name, template, **options|
|
|
601
|
+
operation(verb, name, template, **options)
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# Declare one operation, and define a real method for it.
|
|
606
|
+
#
|
|
607
|
+
# `required:` names the query and header parameters a caller has to
|
|
608
|
+
# pass, by the name the document gave them — which for a header is the
|
|
609
|
+
# name on the wire rather than the Ruby one it arrives under:
|
|
610
|
+
# get :find, "/pets", query: %i[status limit], required: %i[status]
|
|
611
|
+
#
|
|
612
|
+
# `deep_object:` names the query parameters the document gave
|
|
613
|
+
# `style: deepObject`, which go out spelled a key at a time:
|
|
614
|
+
# get :list, "/widgets", query: %i[filter], deep_object: %w[filter]
|
|
615
|
+
#
|
|
616
|
+
# `explode:` names the path and header parameters the document wrote
|
|
617
|
+
# `explode` on, which is the one thing that cannot be read off the value
|
|
618
|
+
# itself: an object goes out as `role=admin,name=alex` where it was said
|
|
619
|
+
# and as `role,admin,name,alex` where it was not.
|
|
620
|
+
def operation(verb, name, template, query: [], deep_object: [], header: {}, explode: [], required: [],
|
|
621
|
+
body: nil, form: nil, multipart: nil, content_type: nil, body_required: false, into: nil,
|
|
622
|
+
errors: {}, security: :inherit)
|
|
623
|
+
path_params = template.scan(/\{(\w+)\}/).flatten
|
|
624
|
+
# A name in `required:` that belongs to no parameter of this operation
|
|
625
|
+
# would quietly leave a required one optional, which is a 400 at the
|
|
626
|
+
# first call rather than an ArgumentError at the first load.
|
|
627
|
+
needed = required.map(&:to_s)
|
|
628
|
+
declared = query.map(&:to_s) + header.keys.map(&:to_s)
|
|
629
|
+
unless (unknown = required.reject { declared.include?(_1.to_s) }).empty?
|
|
630
|
+
raise ArgumentError, "#{self}##{name}: required: names #{unknown.map(&:inspect).join(", ")}, " \
|
|
631
|
+
"which is not a query or header parameter of this operation"
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
query_params = query.map { [_1.to_s, needed.include?(_1.to_s)] }
|
|
635
|
+
header_params = header.map { |json, ruby| [json.to_s, ruby.to_s, needed.include?(json.to_s)] }
|
|
636
|
+
|
|
637
|
+
operations[name] = {
|
|
638
|
+
verb:, template:, body:, form:, multipart:, content_type:, into:, errors:,
|
|
639
|
+
# nil is the operation that said nothing and takes the document's
|
|
640
|
+
# requirement; every other spelling is a requirement of its own.
|
|
641
|
+
security: (security == :inherit ? nil : requirement(security)),
|
|
642
|
+
path: path_params, query: query_params, header: header_params,
|
|
643
|
+
deep_object: deep_object.map(&:to_s), explode: explode.map(&:to_s)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
positional = path_params.map { Keiyaku.snake(_1) }
|
|
647
|
+
# A body is optional unless it was required, which is the default the
|
|
648
|
+
# specification sets and this says nothing more than. The method can
|
|
649
|
+
# then be called without one, and UNSET is how the caller says nothing
|
|
650
|
+
# rather than says nothing in particular: `nil` is a body, and goes out
|
|
651
|
+
# as `null`.
|
|
652
|
+
positional << (body_required ? "body" : "body = Keiyaku::UNSET") if body || form || multipart
|
|
653
|
+
keywords = query_params.map { |param, req| "#{Keiyaku.snake(param)}:#{" Keiyaku::UNSET" unless req}" } +
|
|
654
|
+
header_params.map { |_, ruby, req| "#{ruby}:#{" Keiyaku::UNSET" unless req}" }
|
|
655
|
+
|
|
656
|
+
# `def find(until: nil)` is a method Ruby will define, but `until` in
|
|
657
|
+
# its body starts a loop rather than naming the argument — a keyword
|
|
658
|
+
# argument's label only becomes a readable local by that route. The
|
|
659
|
+
# binding has it under the document's name either way, which is why
|
|
660
|
+
# such a parameter is generated rather than refused.
|
|
661
|
+
read = ->(ruby) { KEYWORDS.include?(ruby) ? "binding.local_variable_get(:#{ruby})" : ruby }
|
|
662
|
+
|
|
663
|
+
arguments = <<~RUBY.chomp
|
|
664
|
+
path: {#{path_params.map { "#{_1.inspect} => #{Keiyaku.snake(_1)}" }.join(", ")}},
|
|
665
|
+
query: {#{query_params.map { |p, _| "#{p.inspect} => #{read.(Keiyaku.snake(p))}" }.join(", ")}},
|
|
666
|
+
header: {#{header_params.map { |json, ruby, _| "#{json.inspect} => #{read.(ruby)}" }.join(", ")}},
|
|
667
|
+
body: #{body || form || multipart ? "body" : "nil"}
|
|
668
|
+
RUBY
|
|
669
|
+
|
|
670
|
+
# Built as source so the method has a real signature: correct arity,
|
|
671
|
+
# correct keyword names, correct errors, and introspectable by tooling.
|
|
672
|
+
class_eval <<~RUBY, __FILE__, __LINE__ + 1
|
|
673
|
+
def #{name}(#{(positional + keywords).join(", ")})
|
|
674
|
+
__invoke(:#{name}, #{arguments})
|
|
675
|
+
end
|
|
676
|
+
RUBY
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# An operation the generator could not build correctly. Declaring it keeps
|
|
680
|
+
# the omission visible instead of leaving a silent hole in the client.
|
|
681
|
+
def unsupported(name, reason)
|
|
682
|
+
operations[name] = { unsupported: reason }
|
|
683
|
+
define_method(name) do |*, **|
|
|
684
|
+
raise Unsupported, "#{self.class}##{name} was not generated: #{reason}"
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
attr_reader :base_url
|
|
690
|
+
|
|
691
|
+
def initialize(base_url: nil, auth: nil, adapter: nil, timeout: 15, retries: 0, logger: nil)
|
|
692
|
+
# A document with no `servers` is ordinary for anything not published on
|
|
693
|
+
# the open internet — a sidecar, something behind a mesh — so the address
|
|
694
|
+
# has to come from the application. Saying so here beats an ArgumentError
|
|
695
|
+
# out of URI at the first call.
|
|
696
|
+
url = base_url || self.class.server
|
|
697
|
+
raise Error, "#{self.class} has no server declared; build it with base_url:" if url.to_s.empty?
|
|
698
|
+
|
|
699
|
+
@base_url = url.chomp("/")
|
|
700
|
+
@credentials = __credentials(auth)
|
|
701
|
+
@adapter = adapter || NetHTTPAdapter.new(timeout:)
|
|
702
|
+
@retries = retries
|
|
703
|
+
@logger = logger
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
private
|
|
707
|
+
|
|
708
|
+
# Credentials, by the name the document gave the scheme. A single value is
|
|
709
|
+
# allowed where there is only one scheme to mean, since naming it would
|
|
710
|
+
# then be ceremony; with two it is refused rather than assigned to
|
|
711
|
+
# whichever came first, which is the mistake that sends an API key to an
|
|
712
|
+
# endpoint that documents OAuth.
|
|
713
|
+
def __credentials(auth)
|
|
714
|
+
schemes = self.class.security
|
|
715
|
+
return {} if auth.nil?
|
|
716
|
+
|
|
717
|
+
if auth.is_a?(Hash)
|
|
718
|
+
credentials = auth.to_h { |name, value| [name.to_sym, value] }
|
|
719
|
+
unknown = credentials.keys - schemes.keys
|
|
720
|
+
unless unknown.empty?
|
|
721
|
+
raise ArgumentError, "#{self.class}: no security scheme named #{unknown.map(&:inspect).join(", ")}; " \
|
|
722
|
+
"#{schemes.empty? ? "the document declares none" : "it declares #{schemes.keys.map(&:inspect).join(", ")}"}"
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
credentials
|
|
726
|
+
elsif schemes.size == 1
|
|
727
|
+
{ schemes.keys.first => auth }
|
|
728
|
+
elsif schemes.empty?
|
|
729
|
+
raise ArgumentError, "#{self.class} declares no security schemes; send credentials as a header parameter"
|
|
730
|
+
else
|
|
731
|
+
raise ArgumentError, "#{self.class} declares #{schemes.keys.map(&:inspect).join(", ")}; " \
|
|
732
|
+
"say which the credential is, as auth: { #{schemes.keys.first}: ... }"
|
|
733
|
+
end
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# The whole of one call: the request this operation describes, and the
|
|
737
|
+
# response read back under the type the document gave it.
|
|
738
|
+
def __invoke(name, path:, query:, header:, body:)
|
|
739
|
+
op = self.class.operations.fetch(name)
|
|
740
|
+
|
|
741
|
+
headers = { "Accept" => "application/json" }
|
|
742
|
+
credentials = []
|
|
743
|
+
__authenticate(name, op, headers, credentials)
|
|
744
|
+
|
|
745
|
+
uri = URI.parse(@base_url + __path(op, path))
|
|
746
|
+
pairs = Serialize.query(query.reject { |_, v| UNSET.equal?(v) }, deep: op[:deep_object]) + credentials
|
|
747
|
+
uri.query = URI.encode_www_form(pairs) unless pairs.empty?
|
|
748
|
+
|
|
749
|
+
# After the credentials, so an explicit parameter of the same name wins.
|
|
750
|
+
header.each do |param, value|
|
|
751
|
+
next if UNSET.equal?(value)
|
|
752
|
+
|
|
753
|
+
headers[param] = Serialize.simple(param, value, explode: op[:explode].include?(param))
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
payload =
|
|
757
|
+
if UNSET.equal?(body)
|
|
758
|
+
# An optional body left out is no body at all: no bytes, and no
|
|
759
|
+
# Content-Type claiming there are some.
|
|
760
|
+
nil
|
|
761
|
+
elsif op[:multipart]
|
|
762
|
+
boundary = "keiyaku-#{SecureRandom.hex(16)}"
|
|
763
|
+
headers["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
|
764
|
+
Serialize.multipart(Keiyaku.dump(body), boundary)
|
|
765
|
+
elsif op[:form]
|
|
766
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
767
|
+
URI.encode_www_form(Keiyaku.dump(body))
|
|
768
|
+
elsif %i[binary text].include?(op[:body])
|
|
769
|
+
headers["Content-Type"] = op[:content_type]
|
|
770
|
+
body.respond_to?(:read) ? body.read : body
|
|
771
|
+
elsif op[:body]
|
|
772
|
+
# The document's own media type where it named one: a `+json` vendor
|
|
773
|
+
# type is these bytes under the name its server documents.
|
|
774
|
+
headers["Content-Type"] = op[:content_type] || "application/json"
|
|
775
|
+
JSON.generate(Keiyaku.dump(body))
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
status, response_headers, raw = __send_with_retries(op[:verb], uri, headers, payload)
|
|
779
|
+
parsed = __parse(raw, response_headers)
|
|
780
|
+
|
|
781
|
+
unless (200..299).cover?(status)
|
|
782
|
+
error_type = Keiyaku.for_status(op[:errors], status)
|
|
783
|
+
parsed = __cast_error(error_type, parsed) if error_type
|
|
784
|
+
klass = status >= 500 ? ServerError : ClientError
|
|
785
|
+
raise klass.new(status:, headers: response_headers, body: raw, parsed:)
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
into = op[:into]
|
|
789
|
+
into = into[status] if into.is_a?(ByStatus)
|
|
790
|
+
into ? Keiyaku.coerce(into, parsed, name.to_s) : parsed
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# The template with each of its parameters written into it, in the same
|
|
794
|
+
# `simple` style a header is written in.
|
|
795
|
+
def __path(op, path)
|
|
796
|
+
op[:template].gsub(/\{(\w+)\}/) { Serialize.path($1, path.fetch($1), explode: op[:explode].include?($1)) }
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
# The error body under the type the document declared for that status —
|
|
800
|
+
# every type, and not only the ones that are a model: an error described
|
|
801
|
+
# as a list of problems is a list of them, and one described as a map was
|
|
802
|
+
# calling Hash#cast, which is a NoMethodError in place of the error the
|
|
803
|
+
# server actually sent.
|
|
804
|
+
#
|
|
805
|
+
# A body that does not fit the type it was declared with is left as it
|
|
806
|
+
# arrived. That is ordinary on this side of the split — what answers a 502
|
|
807
|
+
# is usually written by a proxy that never read the document — and the
|
|
808
|
+
# status is what the caller is rescuing for. Raising a CastError here
|
|
809
|
+
# would take that away, along with the body it would be diagnosed from.
|
|
810
|
+
def __cast_error(type, parsed)
|
|
811
|
+
Keiyaku.coerce(type, parsed, "error")
|
|
812
|
+
rescue CastError
|
|
813
|
+
parsed
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
def __send_with_retries(verb, uri, headers, payload)
|
|
817
|
+
attempt = 0
|
|
818
|
+
|
|
819
|
+
loop do
|
|
820
|
+
begin
|
|
821
|
+
@logger&.debug { "#{verb.to_s.upcase} #{uri}" }
|
|
822
|
+
status, response_headers, raw = @adapter.call(verb, uri, headers, payload)
|
|
823
|
+
rescue ConnectionError, *TRANSPORT_ERRORS => e
|
|
824
|
+
# Nothing came back at all. The shipped adapters have already said
|
|
825
|
+
# so in the one class an application can rescue; one written around
|
|
826
|
+
# another library may not have, and the caller should not have to
|
|
827
|
+
# know which library refused the connection in order to catch it.
|
|
828
|
+
error = e.is_a?(ConnectionError) ? e : ConnectionError.new("#{verb.to_s.upcase} #{uri}: #{e.message}")
|
|
829
|
+
raise error unless attempt < @retries
|
|
830
|
+
|
|
831
|
+
attempt += 1
|
|
832
|
+
sleep(__backoff(attempt))
|
|
833
|
+
next
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
response_headers = __normalize(response_headers)
|
|
837
|
+
return [status, response_headers, raw] unless (status == 429 || status >= 500) && attempt < @retries
|
|
838
|
+
|
|
839
|
+
attempt += 1
|
|
840
|
+
# Retry-After is an instruction; obey it. The fallback is ours.
|
|
841
|
+
sleep(__retry_after(response_headers["retry-after"]) || __backoff(attempt))
|
|
842
|
+
end
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
# RFC 7231 §7.1.3 writes Retry-After two ways: the seconds to wait, or the
|
|
846
|
+
# date at which the wait is over. Both are ordinary, and reading only the
|
|
847
|
+
# first turns the second into an ArgumentError raised from the middle of a
|
|
848
|
+
# retry — out of a call the header was asking to have made again.
|
|
849
|
+
#
|
|
850
|
+
# A header in neither form is no instruction at all and leaves the wait to
|
|
851
|
+
# the backoff. A date already past is one, and says to go now.
|
|
852
|
+
def __retry_after(value)
|
|
853
|
+
return if value.nil?
|
|
854
|
+
|
|
855
|
+
seconds = Float(value, exception: false) || __seconds_until(value)
|
|
856
|
+
seconds && [seconds, 0.0].max
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
def __seconds_until(date)
|
|
860
|
+
Time.httpdate(date) - Time.now
|
|
861
|
+
rescue ArgumentError
|
|
862
|
+
nil
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
# Half the wait fixed, half random, so a fleet that trips the same rate
|
|
866
|
+
# limit at the same moment does not come back at the same moment either.
|
|
867
|
+
# This is deliberately the least it can be: a host application that wants
|
|
868
|
+
# a real retry policy should put the client on an adapter that has one.
|
|
869
|
+
def __backoff(attempt) = 2**attempt * (0.5 + (rand * 0.5))
|
|
870
|
+
|
|
871
|
+
# Every header lookup below is lower-case, which held only by accident of
|
|
872
|
+
# Net::HTTP downcasing its own. Doing it here means an adapter that hands
|
|
873
|
+
# back "Content-Type" cannot silently turn every JSON response into a
|
|
874
|
+
# String. Repeated headers keep the first value.
|
|
875
|
+
def __normalize(headers)
|
|
876
|
+
headers.to_h { |name, value| [name.to_s.downcase, value.is_a?(Array) ? value.first : value] }
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
def __parse(raw, headers)
|
|
880
|
+
return nil if raw.nil? || raw.empty?
|
|
881
|
+
return raw unless headers.fetch("content-type", "").include?("json")
|
|
882
|
+
|
|
883
|
+
JSON.parse(raw)
|
|
884
|
+
rescue JSON::ParserError
|
|
885
|
+
raw
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
# Put on the request exactly what this operation says it needs, which is
|
|
889
|
+
# not necessarily what the document's other operations need. Alternatives
|
|
890
|
+
# are tried in the order the document wrote them, preferring one that
|
|
891
|
+
# actually authenticates: `security: [{}, { api_key: [] }]` is an
|
|
892
|
+
# operation that will serve anonymous callers but should still recognise
|
|
893
|
+
# one who has a key.
|
|
894
|
+
def __authenticate(name, op, headers, credentials)
|
|
895
|
+
alternatives = op[:security] || self.class.default_security
|
|
896
|
+
return if alternatives.empty?
|
|
897
|
+
|
|
898
|
+
chosen = alternatives.reject(&:empty?).find { |schemes| schemes.all? { @credentials.key?(_1) } }
|
|
899
|
+
chosen ||= [] if alternatives.any?(&:empty?)
|
|
900
|
+
|
|
901
|
+
# A client built with no credentials at all is taken at its word: plenty
|
|
902
|
+
# of servers do not enforce what their document declares, and refusing
|
|
903
|
+
# to make the call would be this library deciding otherwise. One built
|
|
904
|
+
# with some, but not the ones this operation names, is a mistake worth
|
|
905
|
+
# more than the 401 it would come back with.
|
|
906
|
+
if chosen.nil?
|
|
907
|
+
return if @credentials.empty?
|
|
908
|
+
|
|
909
|
+
raise Error, "#{self.class}##{name} requires #{alternatives.map { _1.join(" and ") }.join(" or ")}, " \
|
|
910
|
+
"and was built with #{@credentials.keys.join(", ")}"
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
chosen.each { __apply_credential(_1, headers, credentials) }
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
def __apply_credential(scheme, headers, credentials)
|
|
917
|
+
case [self.class.security.fetch(scheme), @credentials.fetch(scheme)]
|
|
918
|
+
in [:bearer, token] then headers["Authorization"] = "Bearer #{token}"
|
|
919
|
+
in [:basic, secret]
|
|
920
|
+
pair = secret.is_a?(Array) ? secret.join(":") : secret.to_s
|
|
921
|
+
headers["Authorization"] = "Basic #{[pair].pack("m0")}"
|
|
922
|
+
in [{ header: key }, token] then headers[key.to_s] = Serialize.stringify(token)
|
|
923
|
+
in [{ query: key }, token] then credentials << [key.to_s, Serialize.stringify(token)]
|
|
924
|
+
in [{ cookie: key }, token]
|
|
925
|
+
headers["Cookie"] = [headers["Cookie"], "#{key}=#{Serialize.stringify(token)}"].compact.join("; ")
|
|
926
|
+
else
|
|
927
|
+
raise Error, "#{self.class}: #{scheme} is declared as #{self.class.security.fetch(scheme).inspect}, " \
|
|
928
|
+
"which is not a scheme this runtime knows how to send"
|
|
929
|
+
end
|
|
930
|
+
end
|
|
931
|
+
end
|
|
932
|
+
end
|