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,968 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "json"
5
+ require "rbconfig"
6
+ require_relative "runtime"
7
+ require_relative "names"
8
+ require_relative "emitter/rbs"
9
+ require_relative "emitter/security"
10
+
11
+ module Keiyaku
12
+ # Turns an OpenAPI document into three files: value types, a client, and RBS.
13
+ #
14
+ # The guiding rule is that anything it cannot translate faithfully becomes a
15
+ # loud refusal rather than plausible-looking code.
16
+ class Emitter
17
+ Refusal = Struct.new(:operation, :reason)
18
+
19
+ # One model class to be emitted. `fields` maps the Ruby name to the Ruby
20
+ # source for its type, `from` to the JSON name where the two differ, and
21
+ # `open` carries what `additionalProperties` said. Being a value, two of
22
+ # these built from the same schema compare equal, which is what lets an
23
+ # inline schema appearing twice be emitted once.
24
+ Model = Data.define(:fields, :required, :from, :open)
25
+
26
+ # Stamped on every file it writes: which generator, and which version of
27
+ # the runtime contract the code was written against.
28
+ HEADER = "# Generated by keiyaku #{VERSION}. Edits will be overwritten."
29
+
30
+ # Where the runtime is, for the process that reads the generated files
31
+ # back. Whether it is also an installed gem is not something to depend on.
32
+ LIB = File.expand_path("..", __dir__)
33
+
34
+ SCALARS = {
35
+ %w[string date-time] => "Time",
36
+ %w[string date] => "Date",
37
+ %w[string binary] => "String",
38
+ %w[string] => "String",
39
+ %w[integer] => "Integer",
40
+ %w[number] => "Float",
41
+ %w[boolean] => ":bool"
42
+ }.freeze
43
+
44
+ attr_reader :refusals, :notes
45
+
46
+ def initialize(path, namespace:)
47
+ @spec = path.end_with?(".json") ? JSON.parse(File.read(path)) : YAML.load_file(path)
48
+ @namespace = Names.namespace(namespace)
49
+ @models = {} # constant name => args for Keiyaku.model(...), or union source
50
+ @unions = {} # union source => its RBS expansion, e.g. "Dog | Cat"
51
+ @deps = {} # constant name => referenced constant names
52
+ @poisoned = {} # constant name => why nothing may be typed as it
53
+ @refusals = []
54
+ @notes = []
55
+ @rbs = RBS.new(namespace: @namespace, models: @models, unions: @unions)
56
+ end
57
+
58
+ # The generated files, and whether Ruby could read them back. Anything
59
+ # already written stays on disk: reading the file the generator could not
60
+ # load is how its remaining mistakes get found.
61
+ attr_reader :broken
62
+
63
+ def emit(dir, verify: true)
64
+ collect_models
65
+ operations = collect_operations
66
+ File.write(File.join(dir, "types.rb"), types_source)
67
+ File.write(File.join(dir, "client.rb"), client_source(operations))
68
+ File.write(File.join(dir, "#{Keiyaku.snake(@namespace)}.rbs"), @rbs.source(sorted_models, operations))
69
+ @broken = verify ? load_check(dir) : nil
70
+ operations
71
+ end
72
+
73
+ # Prose for a generated comment. A refusal quotes the document, and a
74
+ # document's string can hold a newline: interpolated into a `#` comment it
75
+ # would end the comment, and whatever came after it would be read as Ruby.
76
+ # A comment is one line, so this is what a comment can hold.
77
+ def self.comment(text) = text.to_s.gsub(/\s+/, " ").strip
78
+
79
+ private
80
+
81
+ # Only the document in hand. A `$ref` into another file is not a narrower
82
+ # case of this one: taking the last segment of `common.yaml#/Pet` would
83
+ # name the local Pet, and the client would then load, run, and decode one
84
+ # schema as another. Bundle the document first; this says so rather than
85
+ # guessing what was in the file it cannot see.
86
+ def pointer(ref)
87
+ raise Impossible, "$ref #{ref.inspect} is not local to this document; bundle it first" unless ref.start_with?("#/")
88
+
89
+ ref.delete_prefix("#/").split("/").reduce(@spec) do |doc, key|
90
+ # ~1 and ~0 are how a JSON Pointer spells / and ~, which a path
91
+ # template in `#/paths/~1pets/get` is full of.
92
+ key = key.gsub("~1", "/").gsub("~0", "~")
93
+ raise Impossible, "$ref #{ref.inspect} does not resolve" unless doc.is_a?(Hash) && doc.key?(key)
94
+
95
+ doc[key]
96
+ end
97
+ end
98
+
99
+ def resolve(node)
100
+ node.is_a?(Hash) && node["$ref"] ? pointer(node["$ref"]) : node
101
+ end
102
+
103
+ # The constant a `$ref` names, having checked that there is something at
104
+ # the other end of it and that nothing has spoiled the name.
105
+ def const_for(ref)
106
+ pointer(ref)
107
+ const = Names.constant(ref.split("/").last.gsub("~1", "/").gsub("~0", "~"))
108
+ raise Impossible, @poisoned[const] if @poisoned.key?(const)
109
+
110
+ const
111
+ end
112
+
113
+ # Resolved here rather than in Security, which has no document to look a
114
+ # $ref up in. An unresolvable one raises where every other bad $ref does,
115
+ # while the operations are being collected.
116
+ def security_schemes
117
+ (@spec.dig("components", "securitySchemes") || {}).transform_values { resolve(_1) }
118
+ end
119
+
120
+ # --- schemas ------------------------------------------------------------
121
+
122
+ def collect_models
123
+ schemas = @spec.dig("components", "schemas") || {}
124
+
125
+ # Two schemas wanting one constant is settled before either is built:
126
+ # whichever came second would otherwise replace the first, and every
127
+ # operation typed as the first would go on being generated against a
128
+ # model that is no longer there.
129
+ names = {}
130
+ schemas.each_key do |name|
131
+ const = begin
132
+ Names.constant(name)
133
+ rescue Impossible => e
134
+ # Nothing can refer to it either, so whatever does gets the same
135
+ # message where it can be acted on: against the operation.
136
+ next @notes << "schema #{name.inspect} is not emitted: #{e.message}"
137
+ end
138
+
139
+ if (first = names[const])
140
+ poison(const, "schemas #{first.inspect} and #{name.inspect} both map to #{const}")
141
+ else
142
+ names[const] = name
143
+ end
144
+ end
145
+
146
+ schemas.each do |name, schema|
147
+ const = names.key(name) or next
148
+ next if @poisoned.key?(const)
149
+
150
+ begin
151
+ define_model(const, schema)
152
+ rescue Impossible => e
153
+ poison(const, "#{const}: #{e.message}")
154
+ end
155
+ end
156
+
157
+ spread_poison
158
+ end
159
+
160
+ # A component nothing may be typed as. It is a note as well as a poison,
161
+ # because the operation that gets refused for reaching it is refused two
162
+ # models further down, and would otherwise be the only thing said about a
163
+ # schema whose own problem is never mentioned.
164
+ def poison(const, reason)
165
+ @notes << reason
166
+ @poisoned[const] = reason
167
+ end
168
+
169
+ # A model built out of a poisoned one is poisoned too, however far down the
170
+ # chain it sits, so that no operation is typed as something types.rb does
171
+ # not go on to define.
172
+ def spread_poison
173
+ loop do
174
+ spread = @deps.filter_map do |const, deps|
175
+ next if @poisoned.key?(const)
176
+
177
+ culprit = deps.find { @poisoned.key?(_1) }
178
+ [const, "#{const} is built from #{culprit}, which was refused"] if culprit
179
+ end
180
+ break if spread.empty?
181
+
182
+ @poisoned.merge!(spread.to_h)
183
+ end
184
+
185
+ @poisoned.each_key { @models.delete(_1) }
186
+ end
187
+
188
+ # `upload:` is set while translating a multipart body, where a binary
189
+ # string means a file rather than a String.
190
+ def define_model(const, schema, upload: false)
191
+ schema = merge_all_of(schema)
192
+
193
+ # A component that is itself a union is not a model; it is a constant
194
+ # holding the union, so that a $ref to it still resolves.
195
+ if schema["oneOf"] || schema["anyOf"]
196
+ deps = []
197
+ source = collapse_union(schema, const, deps)
198
+ expansion = source ? @rbs.type_for(source) : nil
199
+ source, expansion = union_for(schema, const, deps) unless source
200
+
201
+ @models[const] = source
202
+ @unions[const] = expansion
203
+ @deps[const] = deps.uniq - [const]
204
+ return
205
+ end
206
+
207
+ # Nor is a component that is not an object at all. GitHub has ninety-odd
208
+ # — `author-association` is a string with an enum, `alert-number` an
209
+ # integer — and a model with no fields casts nothing, so a $ref to one
210
+ # produced a client that loaded, typechecked, and then raised on the
211
+ # first response that carried the field. It becomes the type it says it
212
+ # is, the way the union branch above becomes the union it is.
213
+ kind = schema["type"]
214
+ kind = kind - ["null"] if kind.is_a?(Array)
215
+ unless kind.nil? || Array(kind).include?("object") || schema["properties"]
216
+ deps = []
217
+ # An array component and the object inside it are two types, and the
218
+ # document named only one of them. Hoisting the element under the
219
+ # component's own name gets `Widgets = [Widgets]`, so the element is
220
+ # named for being the element.
221
+ context = Array(kind).include?("array") ? "#{const} item" : const
222
+ source = type_for(schema, context, deps)
223
+ @models[const] = source
224
+ @unions[const] = @rbs.type_for(source)
225
+ @deps[const] = deps.uniq - [const]
226
+ return
227
+ end
228
+
229
+ deps = []
230
+ @models[const] = build_model(const, schema, deps, upload:)
231
+ @deps[const] = deps.uniq - [const]
232
+ end
233
+
234
+ # Builds the model without deciding what it is called, so that a caller can
235
+ # compare it against the ones already emitted.
236
+ def build_model(const, schema, deps, upload: false)
237
+ declared = schema["required"] || []
238
+ fields, required, from, seen = {}, [], {}, {}
239
+ open = open_for(schema, const, deps)
240
+
241
+ (schema["properties"] || {}).each do |json_name, property|
242
+ field = Names.field(json_name)
243
+ # Keeping the first would leave a model that quietly drops a property
244
+ # the document declares, and a request body missing a field the caller
245
+ # thought they had set.
246
+ if (first = seen[field])
247
+ raise Impossible, "properties #{first.inspect} and #{json_name.inspect} both become #{field}"
248
+ end
249
+
250
+ seen[field] = json_name
251
+ fields[field] = type_for(property, "#{const}.#{field}", deps, upload:)
252
+ required << field if declared.include?(json_name)
253
+ from[field] = json_name if Keiyaku.camelize(field) != json_name
254
+ end
255
+
256
+ Model.new(fields:, required:, from:, open:)
257
+ end
258
+
259
+ # What `additionalProperties` says about the properties the schema did not
260
+ # name: nothing, anything, or a type they all have. A document that says
261
+ # so means it — DIDComm messages carry headers no schema lists, and the
262
+ # spec has extensions of its own — so the model keeps them rather than
263
+ # dropping them on the way through.
264
+ #
265
+ # Silence stays strict. `additionalProperties` absent is the overwhelming
266
+ # majority of schemas, plenty of which do mean "and nothing else", and a
267
+ # model that quietly accepted anything would take a caller's typo along
268
+ # with it.
269
+ def open_for(schema, const, deps)
270
+ case schema["additionalProperties"]
271
+ when true then "true"
272
+ when Hash then type_for(schema["additionalProperties"], "#{const}{}", deps)
273
+ end
274
+ end
275
+
276
+ def merge_all_of(schema)
277
+ return schema unless schema["allOf"]
278
+
279
+ schema["allOf"].map { merge_all_of(resolve(_1)) }.reduce(schema.except("allOf")) do |acc, part|
280
+ acc.merge(part) do |key, a, b|
281
+ case key
282
+ when "properties" then a.merge(b)
283
+ when "required" then a | b
284
+ else b
285
+ end
286
+ end
287
+ end
288
+ end
289
+
290
+ # Returns Ruby source for a type, recording model dependencies as it goes.
291
+ def type_for(schema, context, deps, upload: false)
292
+ return ":any" if schema.nil? || schema.empty?
293
+
294
+ if (ref = schema["$ref"])
295
+ const = const_for(ref)
296
+ deps << const
297
+ return const
298
+ end
299
+
300
+ schema = merge_all_of(schema) if schema["allOf"]
301
+
302
+ if schema["oneOf"] || schema["anyOf"]
303
+ collapsed = collapse_union(schema, context, deps)
304
+ return collapsed if collapsed
305
+
306
+ source, expansion = union_for(schema, context, deps)
307
+ @unions[source] = expansion unless source == ":any"
308
+ return source
309
+ end
310
+
311
+ # `type: [string, null]` is 3.1's other way of saying a field may be
312
+ # null, and means the same as the `anyOf` spelling collapsed above: the
313
+ # field is a string. More than one type left after dropping null is a
314
+ # real union with nothing to dispatch on, so it falls through to :any
315
+ # rather than picking whichever came first.
316
+ kind = schema["type"]
317
+ if kind.is_a?(Array) && (rest = kind - ["null"]).size == 1
318
+ kind = rest.first
319
+ end
320
+
321
+ case kind
322
+ when "array"
323
+ # A tuple — `items` as a list in draft-07, `prefixItems` in 2020-12 —
324
+ # is a fixed-length heterogeneous array, which one element type cannot
325
+ # describe. Taking the first element's type would be a guess.
326
+ if schema["items"].is_a?(Array) || schema["prefixItems"]
327
+ @notes << "#{context}: a tuple, typed as [:any]"
328
+ return "[:any]"
329
+ end
330
+
331
+ # An array of files is a real multipart shape, so `upload:` carries
332
+ # into the items — but not into a nested object, where a part is JSON
333
+ # and a file would be meaningless.
334
+ "[#{type_for(schema["items"] || {}, "#{context}[]", deps, upload:)}]"
335
+ when "object", nil
336
+ # A schema with properties is a model, and stays one when it also
337
+ # allows properties it did not name: the model carries those. Reading
338
+ # `additionalProperties` first, as this did, threw the named ones away
339
+ # and left a bare map in place of the shape the document described.
340
+ if schema["properties"]
341
+ hoist(context, schema, deps)
342
+ elsif (additional = schema["additionalProperties"]).is_a?(Hash)
343
+ "{ String => #{type_for(additional, "#{context}{}", deps)} }"
344
+ else
345
+ ":any"
346
+ end
347
+ else
348
+ format = [kind, schema["format"]].compact
349
+ if upload && format == %w[string binary]
350
+ ":upload"
351
+ else
352
+ SCALARS[format] || SCALARS[[kind]] || ":any"
353
+ end
354
+ end
355
+ end
356
+
357
+ # Two shapes that are unions only on paper. `anyOf: [{type: string},
358
+ # {type: null}]` is how OpenAPI 3.1 says a field may be null — the field is
359
+ # a string — and a union whose branches all resolve to one type says
360
+ # nothing that type does not. Neither is a guess: there is exactly one type
361
+ # in it. Returns nil when the union is a real one.
362
+ def collapse_union(schema, context, deps)
363
+ variants = (schema["oneOf"] || schema["anyOf"]).reject { _1["type"] == "null" }
364
+ return nil if variants.empty?
365
+ return type_for(variants.first, context, deps) if variants.size == 1
366
+
367
+ # With more than one left, deciding means translating them all, so this
368
+ # only looks at branches that cannot hoist a model — otherwise comparing
369
+ # them could leave behind a type nothing goes on to name.
370
+ return nil unless variants.all? { _1.key?("$ref") || SCALARS.key?([_1["type"]]) }
371
+
372
+ scratch = []
373
+ sources = variants.map { type_for(_1, context, scratch) }
374
+ return nil unless sources.uniq.size == 1
375
+
376
+ deps.concat(scratch)
377
+ sources.first
378
+ end
379
+
380
+ # A union translates only when the document says how to tell the variants
381
+ # apart. Casting by trying each one until something sticks is exactly the
382
+ # plausible-but-wrong behaviour this generator exists to avoid, so an
383
+ # undiscriminated union stays :any and says why.
384
+ #
385
+ # Returns the Ruby source and the RBS that describes it.
386
+ def union_for(schema, context, deps)
387
+ keyword = schema["oneOf"] ? "oneOf" : "anyOf"
388
+ variants = schema[keyword]
389
+ property = schema.dig("discriminator", "propertyName")
390
+
391
+ complaint =
392
+ if property.nil? then "no discriminator"
393
+ elsif !variants.all? { _1.key?("$ref") } then "a variant written inline"
394
+ end
395
+ if complaint
396
+ @notes << "#{context}: #{keyword} with #{complaint}, typed as :any"
397
+ return [":any", "untyped"]
398
+ end
399
+
400
+ consts = variants.map { const_for(_1["$ref"]) }
401
+ # A mapping's value is either a $ref or the bare name of a schema in
402
+ # components, which the specification allows and documents use.
403
+ mapping = (schema["discriminator"]["mapping"] || {}).map do |value, ref|
404
+ [value, const_for(ref.include?("/") ? ref : "#/components/schemas/#{ref}")]
405
+ end
406
+ deps.concat(consts + mapping.map(&:last))
407
+
408
+ args = [*consts, "on: #{property.inspect}"]
409
+ args << "map: { #{mapping.map { |value, const| "#{value.inspect} => #{const}" }.join(", ")} }" if mapping.any?
410
+ ["Keiyaku::OneOf[#{args.join(", ")}]", (consts + mapping.map(&:last)).uniq.join(" | ")]
411
+ end
412
+
413
+ # An inline object gets its own type rather than degrading to a bare Hash,
414
+ # named for where it appeared: the body of an operation is that operation's
415
+ # name, a property is its path down from one. Every such name is a reading
416
+ # of the document rather than a choice, which is the whole of the rule —
417
+ # where the document names a type the name is its own, and where it does
418
+ # not, the position the schema was written in names it.
419
+ #
420
+ # Two schemas that are structurally identical are still two types. Matching
421
+ # them up and keeping the first name would be the generator choosing, and
422
+ # it chooses badly, because the first name is a record of where it happened
423
+ # to walk first: it is how a document with 601 inline request bodies got 56
424
+ # operations sharing a type called after login links, and 25 more sharing
425
+ # one called after a card mandate.
426
+ def hoist(context, schema, deps, upload: false)
427
+ const = Names.constant(context.split(/[.\[\]{}]/).reject(&:empty?).join("_"))
428
+ own = []
429
+ model = build_model(const, merge_all_of(schema), own, upload:)
430
+
431
+ # The same name asked for twice is not two types. Two schemas written in
432
+ # different corners of a document can derive one name — an operation
433
+ # called `pet_error` and a component called `PetError` — and where they
434
+ # turn out to be the same shape they are one model, since agreeing on a
435
+ # name they both derived is not the same as being matched up by
436
+ # structure.
437
+ #
438
+ # Where they disagree, typing it :any instead would leave the caller a
439
+ # Hash where the document describes a shape, and no way to tell which of
440
+ # the two schemas the name ended up meaning.
441
+ raise Impossible, @poisoned[const] if @poisoned.key?(const)
442
+
443
+ if (existing = @models[const])
444
+ raise Impossible, "#{const} already names a different schema" unless existing == model
445
+
446
+ deps << const
447
+ return const
448
+ end
449
+
450
+ @models[const] = model
451
+ @deps[const] = own.uniq - [const]
452
+ deps << const
453
+ const
454
+ end
455
+
456
+ # --- operations ---------------------------------------------------------
457
+
458
+ def collect_operations
459
+ @server = choose_server
460
+
461
+ @security = Security.new(schemes: security_schemes, default: @spec["security"], notes: @notes)
462
+
463
+ operations = (@spec["paths"] || {}).flat_map do |template, path_item|
464
+ begin
465
+ path_item = resolve(path_item)
466
+ rescue Impossible => e
467
+ # There is no operation to refuse yet — the verbs are on the other
468
+ # side of the $ref — so the path goes in the report whole.
469
+ @refusals << Refusal.new(template, e.message)
470
+ next []
471
+ end
472
+
473
+ path_item.slice("get", "put", "post", "delete", "patch", "head", "options").map do |verb, op|
474
+ build_operation(verb, template, op, path_item)
475
+ end
476
+ end
477
+
478
+ # Two operations landing on one name is not a note: whichever the
479
+ # generator wrote second would take the method, and every call meant for
480
+ # the other would go somewhere else entirely. Neither is emitted, and the
481
+ # document has to say which is which.
482
+ operations.group_by { _1[:name] }.each do |name, group|
483
+ next if group.size == 1
484
+
485
+ reason = "#{group.size} operations map to this name (#{group.map { "#{_1[:verb].upcase} #{_1[:template]}" }.join(", ")})"
486
+ @refusals << Refusal.new(name, reason)
487
+ group.each { _1.replace(name:, unsupported: reason) }
488
+ end
489
+
490
+ operations.uniq { _1[:unsupported] ? _1[:name] : _1.object_id }
491
+ end
492
+
493
+ # The one address the generated client is built for. A document may name
494
+ # several, and this takes the first — but says so, because which of them a
495
+ # client talks to is not a detail to discover from traffic.
496
+ #
497
+ # A URL with variables in it is a URL the document has not finished
498
+ # writing: `https://{region}.api.test` is not an address, and substituting
499
+ # a default would be this generator picking a region. It is treated as the
500
+ # document naming no server at all, which is a case the runtime already
501
+ # has an answer for.
502
+ def choose_server
503
+ servers = @spec["servers"] || []
504
+ url = servers.dig(0, "url").to_s
505
+
506
+ if url.empty?
507
+ @notes << "no servers declared; a client has to be built with base_url:"
508
+ return nil
509
+ end
510
+ if url.include?("{")
511
+ @notes << "the server URL #{url} has variables in it; a client has to be built with base_url:"
512
+ return nil
513
+ end
514
+
515
+ @notes << "#{servers.size} servers declared; #{url} is the one generated" if servers.size > 1
516
+ url
517
+ end
518
+
519
+ # `servers` on a path item or an operation is an address this client does
520
+ # not have: the class declares one server, and every method it defines
521
+ # goes there. Where the client's own is among those declared, that is a
522
+ # document listing alternatives and the generator picking one of them.
523
+ # Where it is not, the operation is one whose requests — and whose
524
+ # credentials — would go somewhere the document did not send them, which
525
+ # is a refusal rather than a note.
526
+ def server_problem(op, path_item)
527
+ declared = (op["servers"] || path_item["servers"] || []).filter_map { _1["url"] }
528
+ return if declared.empty? || declared.include?(@server)
529
+
530
+ "declares its own server (#{declared.join(", ")}), and this client is generated for " \
531
+ "#{@server || "no server at all"}"
532
+ end
533
+
534
+ # An operationId is optional, and plenty of documents that a server
535
+ # generates from its own routes carry none, so the verb and the path are
536
+ # all there is to go on. A path parameter becomes `by_x`, which keeps
537
+ # GET /pet and GET /pet/{petId} from arriving at the same method.
538
+ def operation_name(verb, template, op)
539
+ Names.operation(op["operationId"] || "#{verb}_#{template.gsub(/\{(\w+)\}/) { "by_#{$1}" }}")
540
+ end
541
+
542
+ def build_operation(verb, template, op, path_item)
543
+ label = op["operationId"] || "#{verb.upcase} #{template}"
544
+ begin
545
+ name = operation_name(verb, template, op)
546
+ rescue Impossible => e
547
+ # Without a name there is nothing to declare, not even a stub: an
548
+ # `unsupported :class` would define the method that breaks the client.
549
+ @refusals << Refusal.new(label, e.message)
550
+ return { name: nil, label:, unsupported: e.message }
551
+ end
552
+
553
+ translate(verb, template, op, path_item, name)
554
+ rescue Impossible => e
555
+ @refusals << Refusal.new(name, e.message)
556
+ { name:, unsupported: e.message }
557
+ end
558
+
559
+ # What one operation takes: the parameters the path item declared for every
560
+ # operation under it, and the ones the operation declared for itself. A
561
+ # parameter is the pair of its name and where it goes, and an operation
562
+ # naming one the path item already named is narrowing it — a shared `limit`
563
+ # made required here, or given a smaller maximum — rather than asking for a
564
+ # second parameter of the same name. Concatenating the two lists would put
565
+ # it in the client twice, which is a keyword written twice and a file Ruby
566
+ # will not parse; the duplicate check further down would then report the
567
+ # document's legal override as the document's mistake.
568
+ def parameters(op, path_item)
569
+ declared = ((path_item["parameters"] || []) + (op["parameters"] || [])).map { resolve(_1) }
570
+
571
+ # Last wins, and the operation's are last.
572
+ declared.reverse.uniq { [_1["name"], _1["in"]] }.reverse
573
+ end
574
+
575
+ def translate(verb, template, op, path_item, name)
576
+ if (problem = server_problem(op, path_item))
577
+ raise Impossible, problem
578
+ end
579
+
580
+ deps = []
581
+ params = parameters(op, path_item)
582
+ query, deep_object, header, exploded, required, types = [], [], {}, [], [], {}
583
+
584
+ params.each do |param|
585
+ style = param["style"] || (param["in"] == "query" ? "form" : "simple")
586
+ explode = param.fetch("explode", style == "form")
587
+ types[param["name"]] = type_for(param["schema"] || {}, "#{name}_#{Keiyaku.snake(param["name"])}", deps)
588
+
589
+ case param["in"]
590
+ when "path"
591
+ raise Impossible, "path parameter #{param["name"]} uses style=#{style}" unless style == "simple"
592
+
593
+ # Same style as a header, and the same one thing about it that the
594
+ # value cannot be asked.
595
+ exploded << param["name"] if explode
596
+ when "query"
597
+ if style == "deepObject"
598
+ check_deep_object(param)
599
+ deep_object << param["name"]
600
+ else
601
+ raise Impossible, "query parameter #{param["name"]} uses style=#{style}" unless style == "form"
602
+ raise Impossible, "query parameter #{param["name"]} uses explode=false" unless explode
603
+ end
604
+
605
+ query << param["name"]
606
+ when "header"
607
+ header[param["name"]] = Names.parameter(param["name"])
608
+ # A header is written in `simple` style and nothing else, so the
609
+ # only thing left to carry is `explode`, which decides how an object
610
+ # is spelled and cannot be read back off the value at the call.
611
+ exploded << param["name"] if explode
612
+ else
613
+ raise Impossible, "#{param["in"]} parameters are not supported"
614
+ end
615
+
616
+ # A path parameter is positional and required by being one, so this is
617
+ # only about the keywords. The name is the document's, which is the
618
+ # one that appears in `query:` and in `header:` both — the Ruby name a
619
+ # header arrives under is this generator's, and requiredness is not.
620
+ required << param["name"] if param["required"] && param["in"] != "path"
621
+ end
622
+
623
+ body = form = multipart = content_type = nil
624
+ # A request body is optional unless the document says otherwise, which is
625
+ # the specification's default and not this generator's leniency. The
626
+ # difference is a real one at the call site: POST /user with no body is
627
+ # a request the server has an answer for, and a client that cannot make
628
+ # it has to be worked around with a `nil` that goes out as `null`.
629
+ body_required = false
630
+ if (request_body = resolve(op["requestBody"]))
631
+ body_required = request_body["required"] == true
632
+ content = request_body["content"] || {}
633
+ binary = content.keys.find { _1 == "application/octet-stream" || content[_1].dig("schema", "format") == "binary" }
634
+
635
+ # `+json` is the suffix a vendor media type wears to say its bytes are
636
+ # JSON, so a body under any of these is one this can build. Where the
637
+ # document names the plain type as well, that is the one to send: a
638
+ # server naming both takes both, and the vendor spelling usually
639
+ # selects a version of the API nobody has asked this client for.
640
+ jsons = content.keys.select { _1.include?("json") }
641
+ json = jsons.find { _1 == "application/json" } || jsons.first
642
+
643
+ if json
644
+ body = type_for(content[json]["schema"], "#{name}_body", deps)
645
+ # Carried only where it is not the plain type, which is what the
646
+ # runtime sends for a body that says nothing. A server documenting
647
+ # application/vnd.github+json is entitled to refuse the same bytes
648
+ # under another name, so the document's own type goes on the wire.
649
+ content_type = json unless json == "application/json"
650
+ @notes << "#{name}: request body is sent as #{json}, of #{jsons.join(", ")}" if jsons.size > 1
651
+ elsif content.key?("application/x-www-form-urlencoded")
652
+ form = type_for(content["application/x-www-form-urlencoded"]["schema"], "#{name}_body", deps)
653
+ elsif content.key?("multipart/form-data")
654
+ multipart = multipart_type(content["multipart/form-data"]["schema"] || {}, "#{name}_body", deps)
655
+ elsif binary
656
+ body = ":binary"
657
+ content_type = binary
658
+ elsif (text = content.keys.find { _1.start_with?("text/") && text_schema?(content[_1]["schema"]) })
659
+ # A text body is a String sent as it stands, which is what the
660
+ # binary case already does; the media type is the only difference,
661
+ # and the document names it. Where it names several — GitHub's
662
+ # markdown endpoint takes text/plain and text/x-markdown — the
663
+ # first is used and the rest are reported, since the schemas differ
664
+ # only in the header and the caller has no way to say which.
665
+ @notes << "#{name}: request body is sent as #{text}, of #{content.keys.join(", ")}" if content.size > 1
666
+ body = ":text"
667
+ content_type = text
668
+ else
669
+ raise Impossible, "request body is #{content.keys.join(", ")}"
670
+ end
671
+ end
672
+
673
+ # The method's own arguments, which is where two parameter names that
674
+ # normalise to one show up: Ruby would take `foo-bar` and `foo_bar` as
675
+ # one keyword written twice, and refuse to parse the file.
676
+ arguments = template.scan(/\{(\w+)\}/).flatten.map { Names.parameter(_1, positional: true) }
677
+ arguments << "body" if body || form || multipart
678
+ arguments += query.map { Names.parameter(_1) } + header.values
679
+ if (duplicate = arguments.tally.find { |_, count| count > 1 })
680
+ raise Impossible, "two of its parameters are both called #{duplicate.first}"
681
+ end
682
+
683
+ success, errors = responses(op, name, deps)
684
+ into = success.empty? ? nil : success
685
+
686
+ { name:, verb:, template:, query:, deep_object:, header:, exploded:, required:, types:, body:, form:, multipart:,
687
+ content_type:, body_required:, into:, errors:,
688
+ security: @security.source_for(name, op), summary: op["summary"], deps: }
689
+ end
690
+
691
+ # Every success response the document described, by its status. Several of
692
+ # them are several types, and one method returns one value — but nothing
693
+ # has to be guessed to reconcile that, because the document says which
694
+ # type belongs to which status and the response carries the status. GitHub
695
+ # answers a request for a repository's contributor stats with the stats or
696
+ # with a 202 meaning it is still counting them; both are the operation's
697
+ # answer, and casting the second into the first's type is what would need
698
+ # excusing.
699
+ def responses(op, name, deps)
700
+ errors, success = {}, {}
701
+ seen = { "result" => {}, "error" => {} }
702
+
703
+ (op["responses"] || {}).each do |status, response|
704
+ # `x-` is how an object holds something the specification never gave it
705
+ # a field for, and the Responses Object is an object like any other.
706
+ next if status.to_s.start_with?("x-")
707
+
708
+ status = status_for(status)
709
+ content = resolve(response)["content"] || {}
710
+ json = content.keys.find { _1.include?("json") }
711
+ schema = json && content[json]["schema"]
712
+ answered = success_status?(status)
713
+
714
+ if schema
715
+ # Later ones are named for their status, so that two which do not
716
+ # turn out to be the same shape are told apart. Two which do are the
717
+ # document repeating itself inside one operation, and the second has
718
+ # no schema of its own to be named after — which is not the same as
719
+ # two schemas being matched up by structure and one losing its name.
720
+ table, kind = answered ? [success, "result"] : [errors, "error"]
721
+ table[status_key(status)] = seen[kind][schema] ||=
722
+ type_for(schema, "#{name}#{"_#{status}" if table.any?}_#{kind}", deps)
723
+ elsif answered && content.any?
724
+ @notes << "#{name}: #{status} is #{content.keys.join(", ")}, which is returned as the raw body"
725
+ end
726
+ end
727
+
728
+ # Two statuses that turned out to be the same type are one answer, not a
729
+ # table to consult at runtime.
730
+ success = success.first(1).to_h if success.values.uniq.size == 1
731
+ [success, errors]
732
+ end
733
+
734
+ # What a document may write where a response's status goes: a code, one of
735
+ # the five ranges the specification allows in place of one, or `default`.
736
+ # Anything else is not something a response can be matched against, and it
737
+ # is refused here rather than carried: `4xx` written into the client as it
738
+ # stands is not Ruby, and a file that will not parse takes down every other
739
+ # operation in it along with the one that spoiled it.
740
+ def status_for(status)
741
+ status = status.to_s
742
+ return status.upcase if status.match?(/\A[1-5]xx\z/i)
743
+ return status if status == "default" || status.match?(/\A[1-5]\d\d\z/)
744
+
745
+ raise Impossible, "response #{status.inspect} is neither a status code, a range like 4XX, nor default"
746
+ end
747
+
748
+ # A range covering the successes is one of them; `default` is not, being
749
+ # what the document says about everything it did not describe, and an
750
+ # operation whose only answer was a catch-all has no type to return.
751
+ def success_status?(status) = status == "2XX" || status.to_i.between?(200, 299)
752
+
753
+ # The key as the generated client writes it. A range is a String — `2XX` is
754
+ # neither a number nor a name Ruby will take — and the catch-all is the
755
+ # symbol the runtime looks under once the exact code and the range miss.
756
+ def status_key(status)
757
+ return ":default" if status == "default"
758
+
759
+ status.match?(/\A\d+\z/) ? status : status.inspect
760
+ end
761
+
762
+ # `deepObject` has exactly one row in the specification's table of styles:
763
+ # an object, exploded, spelled out a key at a time as filter[status]=live.
764
+ # The array and primitive columns of that row are written n/a, so an array
765
+ # under it — which is what Stripe's `expand` is, on 351 parameters — is not
766
+ # a rendering the specification is quiet about, it is one the specification
767
+ # says does not exist. Guessing at PHP's `expand[]=` or at `expand[0]=` is
768
+ # exactly the kind of plausible wrong answer this generator does not give.
769
+ #
770
+ # `explode` nominally defaults to false outside `form`, but there is no
771
+ # unexploded deepObject to default to. A document that leaves it out is
772
+ # taking the one rendering the style has; a document that writes false is
773
+ # asking for a second one, and gets told there isn't one.
774
+ def check_deep_object(param)
775
+ name = param["name"]
776
+ raise Impossible, "query parameter #{name} uses style=deepObject with explode=false" if param["explode"] == false
777
+ return if object_schema?(param["schema"])
778
+
779
+ raise Impossible, "query parameter #{name} uses style=deepObject on a schema that is not an object"
780
+ end
781
+
782
+ # `type` is how a document says a schema is an object; `properties` is how
783
+ # one that left the type out says it anyway.
784
+ def object_schema?(schema)
785
+ schema = merge_all_of(resolve(schema || {}))
786
+ kind = Array(schema["type"]) - ["null"]
787
+
788
+ kind == ["object"] || (kind.empty? && schema.key?("properties"))
789
+ end
790
+
791
+ # Whether a text/* body is the string it looks like. A document declaring
792
+ # an object under text/csv means an encoding this generator does not know,
793
+ # and passing the model through as the body would send its #to_s.
794
+ def text_schema?(schema)
795
+ kind = merge_all_of(resolve(schema || {}))["type"]
796
+ kind = Array(kind) - ["null"] unless kind.nil?
797
+ kind.nil? || kind == ["string"]
798
+ end
799
+
800
+ # A multipart body always gets its own type, even where the document points
801
+ # at a shared component: `format: binary` means a file here and a plain
802
+ # string everywhere else, and one model cannot mean both.
803
+ def multipart_type(schema, context, deps)
804
+ schema = merge_all_of(resolve(schema))
805
+ return type_for(schema, context, deps) unless schema["properties"]
806
+
807
+ hoist(context, schema, deps, upload: true)
808
+ end
809
+
810
+ # --- output -------------------------------------------------------------
811
+
812
+ # The generator has no business trusting what it just wrote. A constant
813
+ # that turned out to be a local variable, a method named for a keyword, a
814
+ # type referring to a model that never got emitted: none of those are
815
+ # visible in the document, and all of them are visible the moment Ruby
816
+ # reads the file back. This is the last place they can be reported against
817
+ # the document rather than against a stack trace at somebody's first call.
818
+ #
819
+ # It runs in another process, because loading the client here would mean
820
+ # the generator's own namespace, and instantiates it, because a method
821
+ # named for one the client needs is a file that loads and then recurses.
822
+ def load_check(dir)
823
+ script = <<~RUBY
824
+ require #{File.expand_path(File.join(dir, "client")).inspect}
825
+ #{@namespace}::Client.new(base_url: "https://keiyaku.invalid")
826
+ RUBY
827
+ output = IO.popen([RbConfig.ruby, "-I", LIB, "-e", script], err: %i[child out], &:read)
828
+ $?.success? ? nil : output.strip
829
+ end
830
+
831
+ def sorted_models
832
+ ordered, seen = [], {}
833
+ visit = lambda do |const|
834
+ return if seen[const]
835
+
836
+ seen[const] = :visiting
837
+ @deps.fetch(const, []).each { |dep| visit.(dep) unless seen[dep] == :visiting }
838
+ seen[const] = true
839
+ ordered << const
840
+ end
841
+ @models.each_key { visit.(_1) }
842
+ ordered
843
+ end
844
+
845
+ # The fields go in a Hash of their own rather than as keywords beside
846
+ # `required:` and `from:`, because they are the API's names and not ours:
847
+ # a DIDComm message has a property called `from`.
848
+ # A field that is not an identifier is still a Hash label, in quotes:
849
+ # `{ "+1": Integer }` is the same Hash as `{ :"+1" => Integer }`.
850
+ def label(field) = Names.bare?(field) ? field : field.inspect
851
+
852
+ # A name that can be written inside `%w[]` or `%i[]`. Neither literal has
853
+ # any escapes, so the only names they can hold are the ones that need
854
+ # none: every character here is one that cannot end the list or start an
855
+ # interpolation. The trailing bang is the runtime's mark for a required
856
+ # parameter rather than anything the document wrote.
857
+ LISTABLE = /\A[a-zA-Z0-9_.-]+!?\z/
858
+
859
+ # The document's names as source. Written as the word list they read best
860
+ # as where every name is a word, and one `inspect` at a time where one is
861
+ # not — a query parameter called `id]` would otherwise close the literal
862
+ # and leave the rest of its name to be read as Ruby, by the load check
863
+ # first and by every `require` of the generated client after that.
864
+ def symbol_list(names)
865
+ return "%i[#{names.join(" ")}]" if names.all? { _1.match?(LISTABLE) }
866
+
867
+ "[#{names.map { _1.to_sym.inspect }.join(", ")}]"
868
+ end
869
+
870
+ def string_list(names)
871
+ return "%w[#{names.join(" ")}]" if names.all? { _1.match?(LISTABLE) }
872
+
873
+ "[#{names.map(&:inspect).join(", ")}]"
874
+ end
875
+
876
+ def model_options(model, defined = [])
877
+ options = []
878
+ options << "required: %i[#{model.required.join(" ")}]" if model.required.any?
879
+ options << "from: { #{model.from.map { |field, json| "#{label(field)}: #{json.inspect}" }.join(", ")} }" if model.from.any?
880
+ options << "open: #{lazily(model.open, defined)}" if model.open
881
+ options
882
+ end
883
+
884
+ # A schema that contains itself — a tree with child nodes, two models that
885
+ # name each other — cannot be written as the constant it is in the middle
886
+ # of defining. The runtime calls a Proc for the type the first time it
887
+ # casts one, which is late enough for the constant to exist.
888
+ def lazily(source, defined)
889
+ referenced = source.scan(/[A-Z][a-zA-Z0-9]*/).uniq & @models.keys
890
+ referenced.all? { defined.include?(_1) } ? source : "-> { #{source} }"
891
+ end
892
+
893
+ def types_source
894
+ defined = []
895
+ lines = sorted_models.map do |const|
896
+ model = @models[const]
897
+ defined << const
898
+ next " #{const} = #{model}" if model.is_a?(String)
899
+
900
+ fields = model.fields.map { |field, type| "#{label(field)}: #{lazily(type, defined - [const])}" }
901
+ options = model_options(model, defined - [const]).map { ", #{_1}" }.join
902
+ one_line = " #{const} = Keiyaku.model({ #{fields.join(", ")} }#{options})"
903
+ next one_line if one_line.length <= 110
904
+
905
+ " #{const} = Keiyaku.model({\n #{fields.join(",\n ")}\n }#{options})"
906
+ end
907
+
908
+ <<~RUBY
909
+ # frozen_string_literal: true
910
+ #{HEADER}
911
+
912
+ require "keiyaku/runtime"
913
+
914
+ module #{@namespace}
915
+ #{lines.join("\n")}
916
+ end
917
+ RUBY
918
+ end
919
+
920
+ def client_source(operations)
921
+ lines = operations.map do |op|
922
+ next " # cannot be generated: #{Emitter.comment("#{op[:label]}: #{op[:unsupported]}")}" if op[:name].nil?
923
+ next " unsupported :#{op[:name]}, #{op[:unsupported].inspect}" if op[:unsupported]
924
+
925
+ args = [":#{op[:name]}", op[:template].inspect]
926
+ args << "query: #{symbol_list(op[:query])}" if op[:query].any?
927
+ args << "deep_object: #{string_list(op[:deep_object])}" if op[:deep_object].any?
928
+ args << "header: { #{op[:header].map { |json, ruby| "#{json.inspect} => :#{ruby}" }.join(", ")} }" if op[:header].any?
929
+ args << "explode: #{string_list(op[:exploded])}" if op[:exploded].any?
930
+ args << "required: #{symbol_list(op[:required])}" if op[:required].any?
931
+ args << "body: #{op[:body]}" if op[:body]
932
+ args << "form: #{op[:form]}" if op[:form]
933
+ args << "multipart: #{op[:multipart]}" if op[:multipart]
934
+ args << "content_type: #{op[:content_type].inspect}" if op[:content_type]
935
+ args << "body_required: true" if (op[:body] || op[:form] || op[:multipart]) && op[:body_required]
936
+ args << "into: #{into_source(op[:into])}" if op[:into]
937
+ args << "errors: { #{op[:errors].map { |status, type| "#{status} => #{type}" }.join(", ")} }" if op[:errors].any?
938
+ args << "security: #{op[:security]}" if op[:security]
939
+
940
+ " #{op[:verb].ljust(6)} #{args.join(", ")}"
941
+ end
942
+
943
+ <<~RUBY
944
+ # frozen_string_literal: true
945
+ #{HEADER}
946
+
947
+ require_relative "types"
948
+
949
+ module #{@namespace}
950
+ class Client < Keiyaku::Client
951
+ server #{@server.inspect}
952
+ #{@security.table}
953
+ #{lines.join("\n")}
954
+ end
955
+ end
956
+ RUBY
957
+ end
958
+
959
+
960
+ # The operation's `into:`. One success response is the type itself; several
961
+ # are a table for the runtime to read the status against.
962
+ def into_source(into)
963
+ return into.values.first if into.size == 1
964
+
965
+ "Keiyaku::ByStatus[#{into.map { |status, type| "#{status} => #{type}" }.join(", ")}]"
966
+ end
967
+ end
968
+ end