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.
@@ -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