graph_weaver 0.0.1 → 0.2.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 +4 -4
- data/.yardopts +6 -0
- data/CHANGELOG.md +279 -0
- data/Gemfile.lock +75 -17
- data/Makefile +11 -1
- data/NOTES.md +1 -1
- data/PLAN.md +85 -22
- data/README.md +86 -26
- data/docs/cassettes.md +76 -0
- data/docs/errors.md +134 -0
- data/docs/generated_modules.md +229 -0
- data/docs/getting_started.md +134 -0
- data/docs/logging.md +33 -0
- data/docs/real_world.md +53 -0
- data/docs/scalars.md +183 -0
- data/docs/testing.md +103 -0
- data/docs/transports.md +124 -0
- data/graph_weaver.gemspec +10 -1
- data/lib/graph_weaver/client.rb +200 -0
- data/lib/graph_weaver/codegen/emit.rb +286 -0
- data/lib/graph_weaver/codegen/enum_type.rb +149 -0
- data/lib/graph_weaver/codegen/nodes.rb +356 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +256 -0
- data/lib/graph_weaver/codegen.rb +396 -401
- data/lib/graph_weaver/errors.rb +322 -0
- data/lib/graph_weaver/hints.rb +63 -0
- data/lib/graph_weaver/inflect.rb +19 -0
- data/lib/graph_weaver/logging.rb +41 -0
- data/lib/graph_weaver/railtie.rb +29 -0
- data/lib/graph_weaver/response.rb +55 -0
- data/lib/graph_weaver/retry.rb +97 -0
- data/lib/graph_weaver/rspec.rb +59 -0
- data/lib/graph_weaver/schema_loader.rb +208 -8
- data/lib/graph_weaver/selection.rb +68 -0
- data/lib/graph_weaver/tasks.rb +71 -0
- data/lib/graph_weaver/testing/cassette.rb +224 -0
- data/lib/graph_weaver/testing/failure.rb +109 -0
- data/lib/graph_weaver/testing/fake_client.rb +228 -0
- data/lib/graph_weaver/testing/values.rb +98 -0
- data/lib/graph_weaver/testing.rb +106 -0
- data/lib/graph_weaver/transport/faraday.rb +56 -0
- data/lib/graph_weaver/transport/http.rb +87 -0
- data/lib/graph_weaver/transport.rb +120 -0
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +268 -4
- metadata +132 -2
- data/lib/graph_weaver/http_executor.rb +0 -31
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# The typed intermediate representation of a query selection: one node
|
|
5
|
+
# per GraphQL type shape, each knowing its Sorbet prop type and the
|
|
6
|
+
# cast/serialize code to emit.
|
|
7
|
+
class GraphWeaver::Codegen
|
|
8
|
+
class Scalar
|
|
9
|
+
# takes a resolved ScalarType — the generator picks it from the
|
|
10
|
+
# client-scoped overlay or the global registry
|
|
11
|
+
def initialize(scalar_type)
|
|
12
|
+
@scalar = scalar_type
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def bare_type
|
|
16
|
+
@scalar.type
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def prop_type
|
|
20
|
+
# unregistered scalars are already T.untyped — wrapping in
|
|
21
|
+
# T.nilable is redundant and an srb tc error under typed: strict
|
|
22
|
+
bare_type == "T.untyped" ? bare_type : "T.nilable(#{bare_type})"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def cast(expr, _depth)
|
|
26
|
+
@scalar.cast(expr)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def identity?
|
|
30
|
+
!@scalar.cast?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def serialize(expr, _depth)
|
|
34
|
+
@scalar.serialize(expr)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def serialize_identity?
|
|
38
|
+
!@scalar.serialize?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# coercion (opt-in per scalar): accept the value or its raw input and
|
|
42
|
+
# normalize before serializing — parse for a rich type (coerce: true),
|
|
43
|
+
# or a plain conversion for built-ins (coerce: :to_f). See ScalarType.
|
|
44
|
+
def coerce? = @scalar.coerce?
|
|
45
|
+
def coerce(expr) = @scalar.coerce_input(expr)
|
|
46
|
+
def coerce_input_type = @scalar.coerce_type
|
|
47
|
+
|
|
48
|
+
# inside input-struct hashes, scalars coerce exactly like variable
|
|
49
|
+
# kwargs do — the registry (incl. GraphWeaver.auto_coerce) decides
|
|
50
|
+
def hash_coerce(expr, _depth)
|
|
51
|
+
@scalar.coerce_input(expr) || expr
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def hash_coerce_identity? = !@scalar.coerce?
|
|
55
|
+
|
|
56
|
+
def non_null? = false
|
|
57
|
+
def nested = nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class NonNull
|
|
61
|
+
attr_reader :of
|
|
62
|
+
|
|
63
|
+
def initialize(of)
|
|
64
|
+
@of = of
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def bare_type = @of.bare_type
|
|
68
|
+
def prop_type = bare_type
|
|
69
|
+
def cast(expr, depth) = @of.cast(expr, depth)
|
|
70
|
+
def identity? = @of.identity?
|
|
71
|
+
def serialize(expr, depth) = @of.serialize(expr, depth)
|
|
72
|
+
def serialize_identity? = @of.serialize_identity?
|
|
73
|
+
def coerce? = @of.coerce?
|
|
74
|
+
def coerce(expr) = @of.coerce(expr)
|
|
75
|
+
def coerce_input_type = @of.coerce_input_type
|
|
76
|
+
def hash_coerce(expr, depth) = @of.hash_coerce(expr, depth)
|
|
77
|
+
def hash_coerce_identity? = @of.hash_coerce_identity?
|
|
78
|
+
def non_null? = true
|
|
79
|
+
def nested = @of.nested
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class List
|
|
83
|
+
attr_reader :of
|
|
84
|
+
|
|
85
|
+
def initialize(of)
|
|
86
|
+
@of = of
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def bare_type
|
|
90
|
+
"T::Array[#{@of.prop_type}]"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def prop_type
|
|
94
|
+
"T.nilable(#{bare_type})"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def cast(expr, depth)
|
|
98
|
+
var = "v#{depth}"
|
|
99
|
+
element = if @of.non_null? || @of.identity?
|
|
100
|
+
@of.identity? ? var : @of.cast(var, depth + 1)
|
|
101
|
+
else
|
|
102
|
+
"#{var}&.then { |v#{depth + 1}| #{@of.cast("v#{depth + 1}", depth + 2)} }"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
"#{expr}.map { |#{var}| #{element} }"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def identity? = @of.identity?
|
|
109
|
+
|
|
110
|
+
def serialize(expr, depth)
|
|
111
|
+
var = "v#{depth}"
|
|
112
|
+
element = if @of.non_null? || @of.serialize_identity?
|
|
113
|
+
@of.serialize_identity? ? var : @of.serialize(var, depth + 1)
|
|
114
|
+
else
|
|
115
|
+
"#{var}&.then { |v#{depth + 1}| #{@of.serialize("v#{depth + 1}", depth + 2)} }"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
"#{expr}.map { |#{var}| #{element} }"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def serialize_identity? = @of.serialize_identity?
|
|
122
|
+
def coerce? = false
|
|
123
|
+
|
|
124
|
+
def hash_coerce(expr, depth)
|
|
125
|
+
var = "v#{depth}"
|
|
126
|
+
inner = if @of.non_null? || @of.hash_coerce_identity?
|
|
127
|
+
@of.hash_coerce_identity? ? var : @of.hash_coerce(var, depth + 1)
|
|
128
|
+
else
|
|
129
|
+
"#{var}&.then { |v#{depth + 1}| #{@of.hash_coerce("v#{depth + 1}", depth + 2)} }"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
"#{expr}.map { |#{var}| #{inner} }"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def hash_coerce_identity? = @of.hash_coerce_identity?
|
|
136
|
+
def non_null? = false
|
|
137
|
+
def nested = @of.nested
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
class ObjectNode
|
|
141
|
+
Field = Struct.new(:prop, :key, :node)
|
|
142
|
+
|
|
143
|
+
attr_reader :class_name, :fields
|
|
144
|
+
# the GraphQL type this struct was generated from, and any registered
|
|
145
|
+
# helper modules to include (see Codegen.register_type)
|
|
146
|
+
attr_accessor :graphql_type, :mixins
|
|
147
|
+
|
|
148
|
+
def initialize(class_name)
|
|
149
|
+
@class_name = class_name
|
|
150
|
+
@fields = []
|
|
151
|
+
@mixins = []
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def bare_type = class_name
|
|
155
|
+
|
|
156
|
+
def prop_type
|
|
157
|
+
"T.nilable(#{bare_type})"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def cast(expr, _depth)
|
|
161
|
+
"#{class_name}.from_h(#{expr})"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def identity? = false
|
|
165
|
+
def non_null? = false
|
|
166
|
+
def nested = self
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
class EnumNode
|
|
170
|
+
attr_reader :class_name, :values
|
|
171
|
+
|
|
172
|
+
def initialize(class_name, values)
|
|
173
|
+
@class_name = class_name
|
|
174
|
+
@values = values
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def bare_type = class_name
|
|
178
|
+
|
|
179
|
+
def prop_type
|
|
180
|
+
"T.nilable(#{bare_type})"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def cast(expr, _depth)
|
|
184
|
+
"#{class_name}.deserialize(#{expr})"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def identity? = false
|
|
188
|
+
|
|
189
|
+
def serialize(expr, _depth)
|
|
190
|
+
"#{expr}.serialize"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def serialize_identity? = false
|
|
194
|
+
|
|
195
|
+
# enums always coerce: a kwarg or hash field accepts the T::Enum or
|
|
196
|
+
# its wire value (deserialize raises on anything else)
|
|
197
|
+
def coerce? = true
|
|
198
|
+
|
|
199
|
+
def coerce(expr)
|
|
200
|
+
"(#{expr}.is_a?(#{class_name}) ? #{expr} : #{class_name}.deserialize(#{expr}))"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def coerce_input_type = "T.any(#{class_name}, String)"
|
|
204
|
+
def hash_coerce(expr, _depth) = coerce(expr)
|
|
205
|
+
def hash_coerce_identity? = false
|
|
206
|
+
def non_null? = false
|
|
207
|
+
def nested = self
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# A GraphQL enum mapped onto an app-owned T::Enum (see EnumType): no
|
|
211
|
+
# generated enum class — instead module-level <NAME>_FROM_WIRE /
|
|
212
|
+
# <NAME>_TO_WIRE constants translate at the boundary. fallback: makes
|
|
213
|
+
# casting absorb unknown wire values (inputs stay strict).
|
|
214
|
+
class MappedEnum
|
|
215
|
+
attr_reader :graphql_name, :mapping
|
|
216
|
+
|
|
217
|
+
def initialize(enum_type, wire_values)
|
|
218
|
+
@graphql_name = enum_type.graphql_name
|
|
219
|
+
@type_name = enum_type.type.name
|
|
220
|
+
@fallback = enum_type.fallback
|
|
221
|
+
@mapping = enum_type.mapping_for(wire_values)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def const_prefix = GraphWeaver::Inflect.underscore(@graphql_name).upcase
|
|
225
|
+
def fallback_const = @fallback && "#{@type_name}.deserialize(#{@fallback.serialize.to_s.inspect})"
|
|
226
|
+
|
|
227
|
+
def bare_type = @type_name
|
|
228
|
+
|
|
229
|
+
def prop_type
|
|
230
|
+
"T.nilable(#{bare_type})"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def cast(expr, _depth)
|
|
234
|
+
if @fallback
|
|
235
|
+
"#{const_prefix}_FROM_WIRE.fetch(#{expr}) { #{fallback_const} }"
|
|
236
|
+
else
|
|
237
|
+
"#{const_prefix}_FROM_WIRE.fetch(#{expr})"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def identity? = false
|
|
242
|
+
|
|
243
|
+
def serialize(expr, _depth)
|
|
244
|
+
"#{const_prefix}_TO_WIRE.fetch(#{expr})"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def serialize_identity? = false
|
|
248
|
+
|
|
249
|
+
# kwargs and hash fields accept the member or its wire value; unlike
|
|
250
|
+
# casting, bad input raises even with a fallback (a typo'd input is
|
|
251
|
+
# our bug, not server drift)
|
|
252
|
+
def coerce? = true
|
|
253
|
+
|
|
254
|
+
def coerce(expr)
|
|
255
|
+
"(#{expr}.is_a?(#{@type_name}) ? #{expr} : #{const_prefix}_FROM_WIRE.fetch(#{expr}))"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def coerce_input_type = "T.any(#{@type_name}, String)"
|
|
259
|
+
def hash_coerce(expr, _depth) = coerce(expr)
|
|
260
|
+
def hash_coerce_identity? = false
|
|
261
|
+
def non_null? = false
|
|
262
|
+
def nested = nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# A single-condition narrowing of an abstract field (`... on Pet { ... }`
|
|
266
|
+
# and nothing else): the member struct when the runtime type matches,
|
|
267
|
+
# nil when it doesn't — a non-match's response object carries no
|
|
268
|
+
# matching fields, so the hash arrives empty. Always nilable, whatever
|
|
269
|
+
# the schema's nullability, because narrowing filters.
|
|
270
|
+
class NarrowedNode
|
|
271
|
+
def initialize(of)
|
|
272
|
+
@of = of
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def class_name = @of.class_name
|
|
276
|
+
def bare_type = @of.bare_type
|
|
277
|
+
|
|
278
|
+
def prop_type
|
|
279
|
+
"T.nilable(#{bare_type})"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def cast(expr, depth)
|
|
283
|
+
"(#{expr}.empty? ? nil : #{@of.cast(expr, depth)})"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def identity? = false
|
|
287
|
+
def non_null? = false
|
|
288
|
+
def nested = @of
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
class UnionNode
|
|
292
|
+
attr_reader :class_name, :members # graphql type name => ObjectNode
|
|
293
|
+
|
|
294
|
+
def initialize(class_name, members)
|
|
295
|
+
@class_name = class_name
|
|
296
|
+
@members = members
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def bare_type = "#{class_name}::Type"
|
|
300
|
+
|
|
301
|
+
def prop_type
|
|
302
|
+
"T.nilable(#{bare_type})"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def cast(expr, _depth)
|
|
306
|
+
"#{class_name}.from_h(#{expr})"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def identity? = false
|
|
310
|
+
def non_null? = false
|
|
311
|
+
def nested = self
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# An input-object variable: emitted as a module-level T::Struct whose
|
|
315
|
+
# serialize produces the wire hash. Inputs never cast FROM the wire.
|
|
316
|
+
# Joins the coerce protocol so execute kwargs accept plain hashes,
|
|
317
|
+
# normalized (and type-checked) through the generated .coerce.
|
|
318
|
+
class InputNode
|
|
319
|
+
Field = Struct.new(:prop, :wire, :node, :required)
|
|
320
|
+
|
|
321
|
+
attr_reader :class_name, :fields
|
|
322
|
+
|
|
323
|
+
def initialize(class_name)
|
|
324
|
+
@class_name = class_name
|
|
325
|
+
@fields = []
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def bare_type = class_name
|
|
329
|
+
|
|
330
|
+
def prop_type
|
|
331
|
+
"T.nilable(#{bare_type})"
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def serialize(expr, _depth)
|
|
335
|
+
"#{expr}.serialize"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def serialize_identity? = false
|
|
339
|
+
|
|
340
|
+
def cast(_expr, _depth)
|
|
341
|
+
raise NotImplementedError, "input objects are never cast from responses"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def coerce? = true
|
|
345
|
+
def coerce(expr) = "#{class_name}.coerce(#{expr})"
|
|
346
|
+
def coerce_input_type = "T.any(#{class_name}, T::Hash[T.untyped, T.untyped])"
|
|
347
|
+
|
|
348
|
+
# building a struct field from a caller-supplied plain hash value
|
|
349
|
+
def hash_coerce(expr, _depth) = "#{class_name}.coerce(#{expr})"
|
|
350
|
+
def hash_coerce_identity? = false
|
|
351
|
+
|
|
352
|
+
def identity? = false
|
|
353
|
+
def non_null? = false
|
|
354
|
+
def nested = nil
|
|
355
|
+
end
|
|
356
|
+
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "date"
|
|
5
|
+
|
|
6
|
+
class GraphWeaver::Codegen
|
|
7
|
+
|
|
8
|
+
# How one GraphQL scalar maps to Ruby: the Sorbet prop type, the
|
|
9
|
+
# (optional) code emitted to deserialize a wire value into a rich Ruby
|
|
10
|
+
# object and serialize it back, and any requires the generated file
|
|
11
|
+
# needs. A single registry (below) holds one of these per scalar name;
|
|
12
|
+
# the built-in scalars are just pre-registered entries, so custom
|
|
13
|
+
# scalars and overrides go through the same path.
|
|
14
|
+
#
|
|
15
|
+
# cast/serialize normalize to procs that, given a Ruby expression string,
|
|
16
|
+
# return the code to inline. Left nil (the default) they are inferred
|
|
17
|
+
# from the Ruby type when it is a real class, by probing for a known
|
|
18
|
+
# deserializer and pairing its serializer (see CODECS) — so the common
|
|
19
|
+
# case needs no more than a class:
|
|
20
|
+
# type: Money (defines .parse) => Money.parse(expr) / expr.to_s
|
|
21
|
+
# type: Blob (defines .load) => Blob.load(expr) / Blob.dump(expr)
|
|
22
|
+
# Probing the *deserialize* side is deliberate: every object has #to_s,
|
|
23
|
+
# so inferring a serializer off it would wrongly wrap plain types (String,
|
|
24
|
+
# Integer) — pairing off a deserializer the type actually defines avoids
|
|
25
|
+
# that. Override with an explicit value:
|
|
26
|
+
# - a Symbol names a method, so there is no string to misspell:
|
|
27
|
+
# cast: :load => "Blob.load(expr)" (class method on type)
|
|
28
|
+
# serialize: :to_json => "expr.to_json" (instance method)
|
|
29
|
+
# - a Proc handles anything a Symbol can't express:
|
|
30
|
+
# cast: ->(e) { "Money.new(#{e})" }
|
|
31
|
+
# - :itself opts out — force identity pass-through even when a codec
|
|
32
|
+
# would otherwise match (rare)
|
|
33
|
+
# requires: a String or Array of paths emitted as `require`s atop the
|
|
34
|
+
# generated file (e.g. "bigdecimal") so the cast/type resolve.
|
|
35
|
+
class ScalarType
|
|
36
|
+
# Inferred (deserialize, serialize) codecs, tried in order; the first
|
|
37
|
+
# whose probe the Ruby type defines as a class method wins, and its
|
|
38
|
+
# serialize is paired with it. Builders take (type_name, expr) => code.
|
|
39
|
+
Codec = Struct.new(:probe, :cast, :serialize)
|
|
40
|
+
CODECS = [
|
|
41
|
+
Codec.new(:parse, # Type.parse(wire) <-> value.to_s
|
|
42
|
+
->(type, expr) { "#{type}.parse(#{expr})" },
|
|
43
|
+
->(_type, expr) { "#{expr}.to_s" }),
|
|
44
|
+
Codec.new(:load, # Type.load(wire) <-> Type.dump(value)
|
|
45
|
+
->(type, expr) { "#{type}.load(#{expr})" },
|
|
46
|
+
->(type, expr) { "#{type}.dump(#{expr})" }),
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
# Accepted kwarg types for Symbol (instance-method) coercion — the
|
|
50
|
+
# looser inputs the conversion sensibly handles. #to_s is defined on
|
|
51
|
+
# every object, so it accepts anything; #to_f/#to_i only make sense for
|
|
52
|
+
# numerics and strings.
|
|
53
|
+
CONVERT_INPUTS = {
|
|
54
|
+
to_f: "T.any(Float, Integer, String)",
|
|
55
|
+
to_i: "T.any(Integer, Float, String)",
|
|
56
|
+
to_s: "T.anything",
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
attr_reader :graphql_name, :type, :requires
|
|
60
|
+
|
|
61
|
+
def initialize(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
|
|
62
|
+
@graphql_name = graphql_name.to_s
|
|
63
|
+
@klass = type.is_a?(Module) ? type : nil
|
|
64
|
+
@type = type_name(type)
|
|
65
|
+
# requires: load BEFORE codec probing — the probe method may come
|
|
66
|
+
# from the required file (core Time has no .parse until the "time"
|
|
67
|
+
# stdlib loads)
|
|
68
|
+
@requires = normalize_requires(requires)
|
|
69
|
+
codec = @klass && CODECS.find { |c| @klass.respond_to?(c.probe) }
|
|
70
|
+
@cast = normalize_cast(cast, codec&.cast)
|
|
71
|
+
@serialize = normalize_serialize(serialize, codec&.serialize)
|
|
72
|
+
@coerce = coerce
|
|
73
|
+
validate_coerce!
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# conversions applied to the four convertible built-ins when the
|
|
77
|
+
# global GraphWeaver.auto_coerce is on and no explicit coerce: given
|
|
78
|
+
AUTO_CONVERSIONS = {
|
|
79
|
+
"ID" => :to_s, "String" => :to_s, "Int" => :to_i, "Float" => :to_f,
|
|
80
|
+
}.freeze
|
|
81
|
+
|
|
82
|
+
def cast(expr) = @cast&.call(expr)
|
|
83
|
+
def cast? = !@cast.nil?
|
|
84
|
+
def serialize(expr) = @serialize&.call(expr)
|
|
85
|
+
def serialize? = !@serialize.nil?
|
|
86
|
+
def coerce? = !!effective_coerce
|
|
87
|
+
|
|
88
|
+
# Explicit coerce: always wins (false means never). Left unset, the
|
|
89
|
+
# global GraphWeaver.auto_coerce decides — resolved HERE, at
|
|
90
|
+
# generation time, so registration order doesn't matter: convertible
|
|
91
|
+
# built-ins get their conversion, anything with a full cast/serialize
|
|
92
|
+
# pair gets parse-style coercion.
|
|
93
|
+
def effective_coerce
|
|
94
|
+
return @coerce unless @coerce.nil?
|
|
95
|
+
return false unless GraphWeaver.auto_coerce
|
|
96
|
+
|
|
97
|
+
AUTO_CONVERSIONS.fetch(@graphql_name) { (cast? && serialize?) || nil }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The code that normalizes a variable input before it's serialized. Two
|
|
101
|
+
# shapes: coerce: true parses a raw value into the rich type via the cast
|
|
102
|
+
# (guarded so an already-typed value passes through); coerce: :to_f (a
|
|
103
|
+
# Symbol) calls that instance method, for built-ins where a plain
|
|
104
|
+
# conversion is the whole story (5, "5" -> 5.0). serialize still runs
|
|
105
|
+
# afterward, but is identity for the conversion built-ins, so the
|
|
106
|
+
# converted value goes on the wire natively (a Float, not "5.0").
|
|
107
|
+
def coerce_input(expr)
|
|
108
|
+
case effective_coerce
|
|
109
|
+
when true then "(#{expr}.is_a?(#{@type}) ? #{expr} : #{cast(expr)})"
|
|
110
|
+
when Symbol then "#{expr}.#{effective_coerce}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# the accepted Sorbet type for a coercible variable kwarg
|
|
115
|
+
def coerce_type
|
|
116
|
+
case effective_coerce
|
|
117
|
+
when true then "T.any(#{@type}, String)"
|
|
118
|
+
when Symbol then CONVERT_INPUTS.fetch(effective_coerce, "T.untyped")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def type_name(type)
|
|
125
|
+
case type
|
|
126
|
+
when Module then type.name
|
|
127
|
+
when String then type
|
|
128
|
+
else raise ArgumentError, "type: must be a class/module or String, got #{type.inspect}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# nil infers via the matched codec; :itself opts out (identity); a
|
|
133
|
+
# Symbol is a class method on the type — Money.parse(expr)
|
|
134
|
+
def normalize_cast(cast, inferred)
|
|
135
|
+
case cast
|
|
136
|
+
when :itself then nil
|
|
137
|
+
when nil then inferred && ->(expr) { inferred.call(@type, expr) }
|
|
138
|
+
when Proc then cast
|
|
139
|
+
when Symbol then ->(expr) { "#{@type}.#{cast}(#{expr})" }
|
|
140
|
+
else raise ArgumentError, "cast: must be a Symbol, Proc, :itself, or nil, got #{cast.inspect}"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# nil infers via the matched codec; :itself opts out (identity); a
|
|
145
|
+
# Symbol is an instance method on the value — expr.to_s
|
|
146
|
+
def normalize_serialize(serialize, inferred)
|
|
147
|
+
case serialize
|
|
148
|
+
when :itself then nil
|
|
149
|
+
when nil then inferred && ->(expr) { inferred.call(@type, expr) }
|
|
150
|
+
when Proc then serialize
|
|
151
|
+
when Symbol then ->(expr) { "#{expr}.#{serialize}" }
|
|
152
|
+
else raise ArgumentError, "serialize: must be a Symbol, Proc, :itself, or nil, got #{serialize.inspect}"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# requires: is a require path or list of them; each must be a non-empty
|
|
157
|
+
# String (it is emitted verbatim as `require "..."`), caught here rather
|
|
158
|
+
# than as a syntax error in the generated file. When a real class was
|
|
159
|
+
# given as type:, we're in a runtime with its deps loaded, so we also
|
|
160
|
+
# `require` each path to prove it resolves (a no-op for already-loaded
|
|
161
|
+
# libs, and it surfaces a typo now). With only a type-name string we
|
|
162
|
+
# can't assume the lib is installed at codegen time, so we don't try.
|
|
163
|
+
def normalize_requires(requires)
|
|
164
|
+
Array(requires).each do |req|
|
|
165
|
+
unless req.is_a?(String) && !req.empty?
|
|
166
|
+
raise ArgumentError, "requires: must be a String or Array of Strings, got #{req.inspect}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
next unless @klass
|
|
170
|
+
|
|
171
|
+
begin
|
|
172
|
+
require req
|
|
173
|
+
rescue LoadError => e
|
|
174
|
+
raise ArgumentError, "requires: #{req.inspect} is not loadable (#{e.message})"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# coerce: true round-trips through cast+serialize, so it needs both; a
|
|
180
|
+
# Symbol is a self-contained conversion and needs neither.
|
|
181
|
+
def validate_coerce!
|
|
182
|
+
case @coerce
|
|
183
|
+
when false, nil, Symbol then nil
|
|
184
|
+
when true
|
|
185
|
+
return if cast? && serialize?
|
|
186
|
+
|
|
187
|
+
raise ArgumentError,
|
|
188
|
+
"coerce: true needs both a cast and a serialize (#{@graphql_name} is missing one)"
|
|
189
|
+
else
|
|
190
|
+
raise ArgumentError, "coerce: must be true, false, or a Symbol method name, got #{@coerce.inspect}"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
class << self
|
|
196
|
+
# Register (or override) how a GraphQL custom scalar deserializes into
|
|
197
|
+
# a Ruby object and serializes back onto the wire. See ScalarType for
|
|
198
|
+
# the accepted cast:/serialize:/requires: forms. Later registrations
|
|
199
|
+
# win, so an app can override a built-in (e.g. map Date onto its own
|
|
200
|
+
# type).
|
|
201
|
+
def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
|
|
202
|
+
scalar_registry[graphql_name.to_s] =
|
|
203
|
+
ScalarType.new(graphql_name, type, cast:, serialize:, requires:, coerce:)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The ScalarType for a scalar name; unknown scalars fall back to an
|
|
207
|
+
# untyped pass-through (T.untyped, no cast) — the prior behavior for
|
|
208
|
+
# scalars outside the table.
|
|
209
|
+
def scalar(graphql_name)
|
|
210
|
+
scalar_registry.fetch(graphql_name.to_s) do
|
|
211
|
+
ScalarType.new(graphql_name, "T.untyped")
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def scalar_registry
|
|
216
|
+
@scalar_registry ||= {}
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Empty the registry entirely, built-ins included. Mostly useful for
|
|
220
|
+
# tests; see reset_scalars! to restore the built-in defaults.
|
|
221
|
+
def clear_scalars!
|
|
222
|
+
scalar_registry.clear
|
|
223
|
+
self
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Drop every custom registration and restore the built-in scalars — the
|
|
227
|
+
# clean slate to reach for between tests, or to undo overrides. (Want
|
|
228
|
+
# the built-ins to coerce loose input? That's GraphWeaver.auto_coerce,
|
|
229
|
+
# resolved at generation time — no re-registering.)
|
|
230
|
+
def reset_scalars!
|
|
231
|
+
clear_scalars!
|
|
232
|
+
register_builtin_scalars!
|
|
233
|
+
self
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Built-in scalars — pre-registered entries in the one registry. The
|
|
237
|
+
# standard scalars stay pass-through: their Ruby classes (String,
|
|
238
|
+
# Integer, Float) define neither .parse nor .load, so codec inference
|
|
239
|
+
# matches nothing and leaves them identity — which is exactly why we
|
|
240
|
+
# can name them with the real class constants. Date deserializes via
|
|
241
|
+
# ISO-8601 (it *does* define .parse, but we want iso8601 specifically,
|
|
242
|
+
# so it's explicit). Input coercion is a generation-time concern:
|
|
243
|
+
# GraphWeaver.auto_coerce gives the convertible built-ins their
|
|
244
|
+
# conversion (see ScalarType::AUTO_CONVERSIONS).
|
|
245
|
+
def register_builtin_scalars!
|
|
246
|
+
register_scalar "ID", String
|
|
247
|
+
register_scalar "String", String
|
|
248
|
+
register_scalar "Int", Integer
|
|
249
|
+
register_scalar "Float", Float
|
|
250
|
+
register_scalar "Boolean", "T::Boolean"
|
|
251
|
+
register_scalar "Date", Date, cast: :iso8601, serialize: :iso8601, requires: "date"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
register_builtin_scalars!
|
|
256
|
+
end
|