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,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
|