graph_weaver 0.6.0 → 0.7.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/CHANGELOG.md +1470 -1
- data/Gemfile +8 -0
- data/Gemfile.lock +151 -2
- data/README.md +21 -7
- data/docs/alternatives.md +201 -0
- data/docs/cassettes.md +17 -1
- data/docs/errors.md +382 -17
- data/docs/federation.md +469 -63
- data/docs/generated_modules.md +231 -15
- data/docs/getting_started.md +498 -105
- data/docs/i18n.md +234 -0
- data/docs/logging.md +160 -24
- data/docs/real_world.md +32 -4
- data/docs/scalars.md +286 -57
- data/docs/testing.md +458 -59
- data/docs/transports.md +164 -19
- data/docs/upgrading.md +330 -5
- data/graph_weaver.gemspec +7 -0
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +47 -10
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +218 -59
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +408 -206
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/errors.rb +264 -34
- data/lib/graph_weaver/federation.rb +119 -26
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +17 -11
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +78 -0
- data/lib/graph_weaver/internal/headers.rb +51 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +43 -4
- data/lib/graph_weaver/internal.rb +183 -1
- data/lib/graph_weaver/log_subscriber.rb +66 -0
- data/lib/graph_weaver/logging.rb +136 -12
- data/lib/graph_weaver/query_module.rb +36 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +33 -5
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +221 -49
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +6 -5
- data/lib/graph_weaver/testing/endpoint.rb +106 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +133 -44
- data/lib/graph_weaver/testing/router.rb +58 -11
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +46 -4
- data/lib/graph_weaver/transport.rb +109 -26
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +490 -116
- metadata +56 -1
|
@@ -158,8 +158,12 @@ class GraphWeaver::Codegen
|
|
|
158
158
|
else
|
|
159
159
|
"#{var}&.then { |v#{depth + 1}| #{@of.hash_coerce("v#{depth + 1}", depth + 2)} }"
|
|
160
160
|
end
|
|
161
|
+
return "#{expr}.map { |#{var}| #{inner} }" if hash_coerce_identity?
|
|
161
162
|
|
|
162
|
-
|
|
163
|
+
# the index is a path segment — `where._and.0._not.species` needs the 0
|
|
164
|
+
# to name one form field
|
|
165
|
+
idx = "i#{depth}"
|
|
166
|
+
"#{expr}.map.with_index { |#{var}, #{idx}| GraphWeaver::InputStruct.element(#{idx}, #{var}) { #{inner} } }"
|
|
163
167
|
end
|
|
164
168
|
|
|
165
169
|
def hash_coerce_identity? = @of.hash_coerce_identity?
|
|
@@ -190,6 +194,11 @@ class GraphWeaver::Codegen
|
|
|
190
194
|
"#{class_name}.from_h(#{expr})"
|
|
191
195
|
end
|
|
192
196
|
|
|
197
|
+
# a composite renders itself — #as_json is the mirror of .from_h
|
|
198
|
+
def serialize(expr, _depth)
|
|
199
|
+
"#{expr}.as_json"
|
|
200
|
+
end
|
|
201
|
+
|
|
193
202
|
def nested = self
|
|
194
203
|
end
|
|
195
204
|
|
|
@@ -253,8 +262,16 @@ class GraphWeaver::Codegen
|
|
|
253
262
|
end
|
|
254
263
|
end
|
|
255
264
|
|
|
265
|
+
# The fallback member is in no wire table — several wire values collapse
|
|
266
|
+
# into it, so `invert` keeps none — and it is exactly the member a drifted
|
|
267
|
+
# response casts to. Its own #serialize is the only spelling left, and it
|
|
268
|
+
# casts back to the fallback, so a result still round-trips through
|
|
269
|
+
# #as_json. Without a fallback the table is total and a miss is a real
|
|
270
|
+
# mistake, so it still raises.
|
|
256
271
|
def serialize(expr, _depth)
|
|
257
|
-
"#{const_prefix}_TO_WIRE.fetch(#{expr})"
|
|
272
|
+
return "#{const_prefix}_TO_WIRE.fetch(#{expr})" unless @fallback
|
|
273
|
+
|
|
274
|
+
"#{const_prefix}_TO_WIRE.fetch(#{expr}) { |member| member.serialize }"
|
|
258
275
|
end
|
|
259
276
|
|
|
260
277
|
def leaf? = true
|
|
@@ -298,6 +315,8 @@ class GraphWeaver::Codegen
|
|
|
298
315
|
end
|
|
299
316
|
end
|
|
300
317
|
|
|
318
|
+
def serialize(expr, depth) = @of.serialize(expr, depth)
|
|
319
|
+
def serialize_identity? = @of.serialize_identity?
|
|
301
320
|
def nested = @of
|
|
302
321
|
end
|
|
303
322
|
|
|
@@ -306,10 +325,13 @@ class GraphWeaver::Codegen
|
|
|
306
325
|
# alphabetically first of their keys, which the walk may reach second
|
|
307
326
|
attr_accessor :class_name
|
|
308
327
|
attr_reader :members # graphql type name => ObjectNode
|
|
309
|
-
#
|
|
328
|
+
# The struct an unnamed (or newly-added) __typename deserializes into.
|
|
329
|
+
# Required, not defaulted: a dispatch without one has to refuse a member
|
|
330
|
+
# the schema grew, and "the query named every member today" is not a
|
|
331
|
+
# reason to break tomorrow's response.
|
|
310
332
|
attr_reader :catch_all
|
|
311
333
|
|
|
312
|
-
def initialize(class_name, members, catch_all
|
|
334
|
+
def initialize(class_name, members, catch_all)
|
|
313
335
|
@class_name = class_name
|
|
314
336
|
@members = members
|
|
315
337
|
@catch_all = catch_all
|
|
@@ -321,6 +343,10 @@ class GraphWeaver::Codegen
|
|
|
321
343
|
"#{class_name}.from_h(#{expr})"
|
|
322
344
|
end
|
|
323
345
|
|
|
346
|
+
def serialize(expr, _depth)
|
|
347
|
+
"#{expr}.as_json"
|
|
348
|
+
end
|
|
349
|
+
|
|
324
350
|
def nested = self
|
|
325
351
|
end
|
|
326
352
|
|
|
@@ -342,6 +368,10 @@ class GraphWeaver::Codegen
|
|
|
342
368
|
def cast(expr, _depth)
|
|
343
369
|
"#{class_name}.from_h(#{expr})"
|
|
344
370
|
end
|
|
371
|
+
|
|
372
|
+
def serialize(expr, _depth)
|
|
373
|
+
"#{expr}.as_json"
|
|
374
|
+
end
|
|
345
375
|
end
|
|
346
376
|
|
|
347
377
|
# An input-object variable: emitted as a module-level T::Struct whose
|
|
@@ -351,14 +381,17 @@ class GraphWeaver::Codegen
|
|
|
351
381
|
class InputNode < Node
|
|
352
382
|
Field = Struct.new(:prop, :wire, :node, :required)
|
|
353
383
|
|
|
354
|
-
|
|
384
|
+
# graphql_name as well as class_name: a schema coordinate is spelled the
|
|
385
|
+
# schema's way (pokemon_bool_exp.name), which camelize has already lost.
|
|
386
|
+
attr_reader :class_name, :graphql_name, :fields
|
|
355
387
|
# @oneOf: exactly one field may be supplied. The schema can't say so — every
|
|
356
388
|
# @oneOf field is nullable — so the generated struct carries the flag and
|
|
357
389
|
# InputStruct#serialize enforces it.
|
|
358
390
|
attr_accessor :one_of
|
|
359
391
|
|
|
360
|
-
def initialize(class_name)
|
|
392
|
+
def initialize(class_name, graphql_name = class_name)
|
|
361
393
|
@class_name = class_name
|
|
394
|
+
@graphql_name = graphql_name
|
|
362
395
|
@fields = []
|
|
363
396
|
@one_of = false
|
|
364
397
|
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# One graph's registrations: the scalar codecs, the enum mappings, and the
|
|
5
|
+
# type helpers a generation reads. The three tables move together because a
|
|
6
|
+
# registration is scoped to the schema it describes — an app with two schemas
|
|
7
|
+
# registers `Money` for each, or for only one, and neither answer is the
|
|
8
|
+
# other's (see GraphWeaver.graph).
|
|
9
|
+
#
|
|
10
|
+
# The methods themselves live in Registrations, which the three codegen/*.rb
|
|
11
|
+
# files fill in; this is where they get somewhere to write. Codegen's class
|
|
12
|
+
# methods (GraphWeaver.register_scalar and friends) delegate here, to the
|
|
13
|
+
# default graph's registry — so a single-schema app never meets this class.
|
|
14
|
+
|
|
15
|
+
class GraphWeaver::Codegen
|
|
16
|
+
# The registrations one graph generates with: the scalar codecs, the enum
|
|
17
|
+
# mappings and the type helpers, plus what a schema can say about them.
|
|
18
|
+
# Not API (private_constant below) — an app writes register_scalar/
|
|
19
|
+
# register_enum/extend_type, at the top level (the default graph) or in a
|
|
20
|
+
# graph block, and never names the object holding them.
|
|
21
|
+
# codegen/{scalar,enum,type_helpers}.rb fill in the registering half.
|
|
22
|
+
class Registry
|
|
23
|
+
# What a registry's names must be in the schema. extend_type decorates
|
|
24
|
+
# whatever composite a query reaches, so it demands no particular kind.
|
|
25
|
+
REGISTERED_KIND = { "scalar" => "SCALAR", "enum" => "ENUM" }.freeze
|
|
26
|
+
# the type registry is reached via extend_type; scalars/enums via register_*
|
|
27
|
+
REGISTRATION_METHOD = { "type" => "extend_type", "scalar" => "register_scalar", "enum" => "register_enum" }.freeze
|
|
28
|
+
private_constant :REGISTERED_KIND, :REGISTRATION_METHOD
|
|
29
|
+
|
|
30
|
+
# Every registration this schema can't match, one sentence each. The answer
|
|
31
|
+
# depends on the schema and the registry alone, not on any one document, so
|
|
32
|
+
# a whole generate! run gets the same list — which is what lets the build
|
|
33
|
+
# report it once (see GraphWeaver.unmatched_registrations).
|
|
34
|
+
#
|
|
35
|
+
# The built-in scalars are pre-registered entries in the same table rather
|
|
36
|
+
# than user intent, so they're exempt — a schema with no Date scalar is not
|
|
37
|
+
# a mistake.
|
|
38
|
+
def unmatched_registrations(schema)
|
|
39
|
+
{
|
|
40
|
+
"enum" => enum_registry,
|
|
41
|
+
"scalar" => scalar_registry.except(*BUILTIN_SCALARS),
|
|
42
|
+
"type" => type_registry,
|
|
43
|
+
}.flat_map do |kind, registry|
|
|
44
|
+
registry.keys.filter_map { |name| validate_registration!(schema, kind, name) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A registry serves one graph, but a generation sees one schema — so a
|
|
49
|
+
# registration fails generation only where THIS schema can disprove it: a
|
|
50
|
+
# name it declares as something else, or a coordinate whose field it declares
|
|
51
|
+
# as a composite. A name it can't match at all proves nothing, because an
|
|
52
|
+
# entity type is declared by every subgraph that references it while its
|
|
53
|
+
# fields are split among them; that returns the sentence to say instead.
|
|
54
|
+
def validate_registration!(schema, kind, name)
|
|
55
|
+
method = REGISTRATION_METHOD.fetch(kind)
|
|
56
|
+
# register_scalar("Type.field", ...) overrides one field's scalar — validate
|
|
57
|
+
# the field, not that a type named "Type.field" exists.
|
|
58
|
+
return validate_scalar_field!(schema, name, method) if kind == "scalar" && name.include?(".")
|
|
59
|
+
|
|
60
|
+
type = schema.get_type(name)
|
|
61
|
+
unless type
|
|
62
|
+
return unmatched(schema, method, name, kind, GraphWeaver::Internal::Util.did_you_mean(schema.types.keys, name))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
expected = REGISTERED_KIND[kind]
|
|
66
|
+
return if expected.nil? || type.kind.name == expected
|
|
67
|
+
|
|
68
|
+
found = type.kind.name.downcase.tr("_", " ")
|
|
69
|
+
# a leaf registered as the other kind has a method that would have worked
|
|
70
|
+
other = REGISTERED_KIND.key(type.kind.name)
|
|
71
|
+
raise GraphWeaver::Error,
|
|
72
|
+
"#{method}(#{name.inspect}) names #{article(found)} #{found}, not #{article(kind)} " \
|
|
73
|
+
"#{kind}#{other ? " — use #{REGISTRATION_METHOD.fetch(other)}" : ""}"
|
|
74
|
+
end
|
|
75
|
+
private :validate_registration!
|
|
76
|
+
|
|
77
|
+
# A per-field override, register_scalar("Type.field", ...). Neither an absent
|
|
78
|
+
# type nor an absent field is disprovable here; what is, is a field this
|
|
79
|
+
# schema declares as something a scalar codec could never read.
|
|
80
|
+
def validate_scalar_field!(schema, name, method)
|
|
81
|
+
type_name, field_name = name.split(".", 2)
|
|
82
|
+
type = schema.get_type(type_name)
|
|
83
|
+
unless type
|
|
84
|
+
near = GraphWeaver::Internal::Util.did_you_mean(schema.types.keys, type_name)
|
|
85
|
+
return unmatched(schema, method, name, "scalar field", near && "#{near}.#{field_name}")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
fields = type.respond_to?(:fields) ? type.fields : {}
|
|
89
|
+
field = fields[field_name]
|
|
90
|
+
unless field
|
|
91
|
+
near = GraphWeaver::Internal::Util.did_you_mean(fields.keys, field_name)
|
|
92
|
+
return unmatched(schema, method, name, "scalar field", near && "#{type_name}.#{near}")
|
|
93
|
+
end
|
|
94
|
+
return if field.type.unwrap.kind.name == "SCALAR"
|
|
95
|
+
|
|
96
|
+
raise GraphWeaver::Error,
|
|
97
|
+
"#{method}(#{name.inspect}): #{name} isn't a scalar field (it's #{field.type.unwrap.kind.name.downcase})"
|
|
98
|
+
end
|
|
99
|
+
private :validate_scalar_field!
|
|
100
|
+
|
|
101
|
+
# What to say about a name this schema has nothing for. Registrations are
|
|
102
|
+
# graph-scoped — federation composes by name, so one `Money` codec serves
|
|
103
|
+
# every subgraph that declares it — which is exactly why this schema can't
|
|
104
|
+
# tell a typo from a registration for the subgraph next door. Say both.
|
|
105
|
+
def unmatched(schema, method, name, what, suggestion)
|
|
106
|
+
hint = suggestion ? " (did you mean '#{suggestion}'?)" : ""
|
|
107
|
+
"#{method}(#{name.inspect}) matches no #{what} in #{schema.name || "this schema"} " \
|
|
108
|
+
"— a typo#{hint}, or a registration for another schema"
|
|
109
|
+
end
|
|
110
|
+
private :unmatched
|
|
111
|
+
|
|
112
|
+
def article(word) = GraphWeaver::Internal::Util.article(word)
|
|
113
|
+
private :article
|
|
114
|
+
|
|
115
|
+
# Every table back to its starting state — scalars (built-ins restored),
|
|
116
|
+
# enum mappings, and type helpers. The clean slate between tests, and the
|
|
117
|
+
# one call that stays right when a fourth kind of registration shows up.
|
|
118
|
+
def reset_registrations!
|
|
119
|
+
reset_scalars!
|
|
120
|
+
reset_enums!
|
|
121
|
+
reset_type_helpers!
|
|
122
|
+
self
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def initialize = register_builtin_scalars!
|
|
126
|
+
|
|
127
|
+
# A graph starts from the top-level registrations and adds its own, so the
|
|
128
|
+
# three tables are copied rather than shared — an app that registered Money
|
|
129
|
+
# before it had a second schema keeps it, and a graph block can't reach back.
|
|
130
|
+
def initialize_copy(other)
|
|
131
|
+
super
|
|
132
|
+
@scalar_registry = other.scalar_registry.dup
|
|
133
|
+
@enum_registry = other.enum_registry.dup
|
|
134
|
+
# the entry is a hash of mutable arrays, so each one is copied too
|
|
135
|
+
@type_registry = other.type_registry.transform_values { |e| e.transform_values(&:dup) }
|
|
136
|
+
@helper_counts = other.send(:helper_counts).dup
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The default graph's registrations — where a top-level
|
|
141
|
+
# GraphWeaver.register_scalar writes, and what a generation uses unless a
|
|
142
|
+
# graph hands it its own.
|
|
143
|
+
def self.registry = @registry ||= Registry.new
|
|
144
|
+
|
|
145
|
+
# Pre-registered rather than user intent, so generation doesn't hold a schema
|
|
146
|
+
# to them (validate_registration! skips these). Read off a fresh registry: a
|
|
147
|
+
# seventh built-in shouldn't have to be named twice.
|
|
148
|
+
BUILTIN_SCALARS = Registry.new.scalar_registry.keys.freeze
|
|
149
|
+
|
|
150
|
+
class << self
|
|
151
|
+
# The default graph's registry answers every one of these — the surface an
|
|
152
|
+
# app has used since before graphs existed, unchanged.
|
|
153
|
+
def register_scalar(...) = registry.register_scalar(...)
|
|
154
|
+
def register_enum(...) = registry.register_enum(...)
|
|
155
|
+
def extend_type(...) = registry.extend_type(...)
|
|
156
|
+
def scalar(...) = registry.scalar(...)
|
|
157
|
+
def scalar_registry = registry.scalar_registry
|
|
158
|
+
def enum_registry = registry.enum_registry
|
|
159
|
+
def type_registry = registry.type_registry
|
|
160
|
+
def unmatched_registrations(...) = registry.unmatched_registrations(...)
|
|
161
|
+
def clear_scalars! = registry.clear_scalars! && self
|
|
162
|
+
def reset_scalars! = registry.reset_scalars! && self
|
|
163
|
+
def reset_enums! = registry.reset_enums! && self
|
|
164
|
+
def reset_type_helpers! = registry.reset_type_helpers! && self
|
|
165
|
+
|
|
166
|
+
# Returns Codegen, not the registry: these are the documented calls, and
|
|
167
|
+
# their value has always been something you can keep chaining off.
|
|
168
|
+
def reset_registrations! = registry.reset_registrations! && self
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Nothing outside the gem names it, and it can't move under Internal either:
|
|
172
|
+
# its methods build a ScalarType and an EnumType, which are private here.
|
|
173
|
+
# Private at load, not in a method body — see spec/registry_spec.rb.
|
|
174
|
+
private_constant :Registry
|
|
175
|
+
end
|
|
@@ -12,10 +12,12 @@ class GraphWeaver::Codegen
|
|
|
12
12
|
# scalars and overrides go through the same path.
|
|
13
13
|
#
|
|
14
14
|
# cast/serialize normalize to procs that, given a Ruby expression string,
|
|
15
|
-
# return the code to inline. Left nil (the default) they are inferred
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
15
|
+
# return the code to inline. Left nil (the default) they are inferred from
|
|
16
|
+
# the Ruby type: a type the library already knows takes its codec from
|
|
17
|
+
# STDLIB, otherwise it is probed for a known deserializer whose serializer
|
|
18
|
+
# pairs with it (see CODECS), and failing that for a Kernel conversion
|
|
19
|
+
# function of its own name — so the common case needs no more than a class:
|
|
20
|
+
# type: BigDecimal (Kernel#BigDecimal) => BigDecimal(expr) / expr.to_s("F")
|
|
19
21
|
# type: Money (defines .parse) => Money.parse(expr) / expr.to_s
|
|
20
22
|
# type: Blob (defines .load) => Blob.load(expr) / Blob.dump(expr)
|
|
21
23
|
# Probing the *deserialize* side is deliberate: every object has #to_s,
|
|
@@ -25,6 +27,7 @@ class GraphWeaver::Codegen
|
|
|
25
27
|
# - a Symbol names a method, so there is no string to misspell:
|
|
26
28
|
# cast: :load => "Blob.load(expr)" (class method on type)
|
|
27
29
|
# serialize: :to_json => "expr.to_json" (instance method)
|
|
30
|
+
# - an Array is that method with arguments: serialize: [:to_s, "F"]
|
|
28
31
|
# - a Proc handles anything a Symbol can't express:
|
|
29
32
|
# cast: ->(e) { "Money.new(#{e})" }
|
|
30
33
|
# - :itself opts out — force identity pass-through even when a codec
|
|
@@ -49,17 +52,55 @@ class GraphWeaver::Codegen
|
|
|
49
52
|
->(klass, value) { klass.dump(value) }),
|
|
50
53
|
].freeze
|
|
51
54
|
|
|
52
|
-
# A scalar
|
|
53
|
-
# whole rule, so key on that — a custom scalar registered as a
|
|
54
|
-
# String gets the same check. ID is the exception GraphQL itself
|
|
55
|
-
# (see Coerce.id), matched by GraphQL name in #coercer.
|
|
55
|
+
# A scalar whose registration named no `cast:` of its own: its Ruby type
|
|
56
|
+
# is the whole rule, so key on that — a custom scalar registered as a
|
|
57
|
+
# plain String gets the same check. ID is the exception GraphQL itself
|
|
58
|
+
# names (see Coerce.id), matched by GraphQL name in #coercer.
|
|
56
59
|
COERCERS = {
|
|
57
60
|
"Integer" => "integer",
|
|
58
61
|
"Float" => "float",
|
|
59
62
|
"String" => "string",
|
|
60
63
|
"T::Boolean" => "boolean",
|
|
64
|
+
"Date" => "date",
|
|
65
|
+
"Time" => "time",
|
|
61
66
|
}.freeze
|
|
62
|
-
|
|
67
|
+
|
|
68
|
+
# What the library already knows about a Ruby type, so registering one
|
|
69
|
+
# takes nothing but the class. Consulted only where the registration is
|
|
70
|
+
# silent; an explicit cast:/serialize:/requires: wins. Two things the
|
|
71
|
+
# probe above can't work out on its own:
|
|
72
|
+
# - the wire spelling. BigDecimal#to_s writes "0.125e2", which is not
|
|
73
|
+
# what any server means by 12.5, and Date.parse reads a great deal
|
|
74
|
+
# more than the ISO 8601 a Date scalar carries.
|
|
75
|
+
# - the file to require, so the generated source stands alone.
|
|
76
|
+
# Only types whose wire form is unambiguous belong here.
|
|
77
|
+
#
|
|
78
|
+
# `call:` is the runnable twin of a Proc `serialize:`, which builds source
|
|
79
|
+
# and so can't be run — the testing harness needs both (see
|
|
80
|
+
# #serialize_value).
|
|
81
|
+
TIMESTAMP = ->(expr) { "GraphWeaver::Coerce.timestamp(#{expr})" }
|
|
82
|
+
TIMESTAMP_CALL = ->(value) { GraphWeaver::Coerce.timestamp(value) }
|
|
83
|
+
|
|
84
|
+
STDLIB = {
|
|
85
|
+
"BigDecimal" => { serialize: [:to_s, "F"], requires: "bigdecimal" },
|
|
86
|
+
# JSON has one number type, so a whole Float arrives as `1` from every
|
|
87
|
+
# encoder that drops the trailing zero (graphql-js and Go both do)
|
|
88
|
+
"Float" => { cast: ->(expr) { "GraphWeaver::Coerce.float(#{expr})" } },
|
|
89
|
+
# strftime, not #iso8601: DateTime < Date passes the is_a? guard, and its
|
|
90
|
+
# #iso8601 writes a timestamp where the schema said a date goes
|
|
91
|
+
"Date" => { cast: :iso8601, serialize: [:strftime, "%F"], requires: "date" },
|
|
92
|
+
# #iso8601 takes no precision, so it writes whole seconds and a
|
|
93
|
+
# sub-second timestamp goes back out poorer than it came in
|
|
94
|
+
"Time" => { cast: :parse, serialize: TIMESTAMP, call: TIMESTAMP_CALL, requires: "time" },
|
|
95
|
+
"DateTime" => { cast: :iso8601, serialize: TIMESTAMP, call: TIMESTAMP_CALL, requires: "date" },
|
|
96
|
+
}.freeze
|
|
97
|
+
|
|
98
|
+
# Everything JSON.parse can hand back. A registered type outside this set
|
|
99
|
+
# has to be BUILT from one of them, which is what a cast is for (and what
|
|
100
|
+
# Codegen#refuse_uncastable! insists on).
|
|
101
|
+
WIRE_CLASSES = [String, Integer, Float, Hash, Array, TrueClass, FalseClass].freeze
|
|
102
|
+
|
|
103
|
+
private_constant :Codec, :CODECS, :COERCERS, :STDLIB
|
|
63
104
|
|
|
64
105
|
attr_reader :graphql_name, :type, :requires
|
|
65
106
|
|
|
@@ -67,14 +108,25 @@ class GraphWeaver::Codegen
|
|
|
67
108
|
@graphql_name = graphql_name.to_s
|
|
68
109
|
@klass = type.is_a?(Module) ? type : nil
|
|
69
110
|
@type = type_name(type)
|
|
70
|
-
|
|
71
|
-
#
|
|
72
|
-
# stdlib loads
|
|
73
|
-
|
|
111
|
+
known = STDLIB[@type] || {}
|
|
112
|
+
# requires: load BEFORE probing — the deserializer may arrive with the
|
|
113
|
+
# file (core Time has no .parse until the "time" stdlib loads, and
|
|
114
|
+
# Kernel#BigDecimal none until "bigdecimal" does). A path from STDLIB
|
|
115
|
+
# is the library's own, so it loads even for a type: given as a String,
|
|
116
|
+
# whose dependency we otherwise can't assume is installed.
|
|
117
|
+
@requires =
|
|
118
|
+
if requires.nil?
|
|
119
|
+
GraphWeaver::Codegen.normalize_requires!(known[:requires], load: true)
|
|
120
|
+
else
|
|
121
|
+
GraphWeaver::Codegen.normalize_requires!(requires, load: !@klass.nil?)
|
|
122
|
+
end
|
|
123
|
+
@cast_given = cast unless cast == :itself
|
|
74
124
|
codec = @klass && CODECS.find { |c| @klass.respond_to?(c.probe) }
|
|
75
|
-
@cast = normalize_cast(cast, codec&.cast)
|
|
76
|
-
@serialize = normalize_serialize(serialize, codec&.serialize)
|
|
77
|
-
@serialize_value =
|
|
125
|
+
@cast = normalize_cast(cast || known[:cast], codec&.cast || kernel_cast)
|
|
126
|
+
@serialize = normalize_serialize(serialize || known[:serialize], codec&.serialize)
|
|
127
|
+
@serialize_value = (known[:call] if serialize.nil?) ||
|
|
128
|
+
runtime_serialize(serialize || known[:serialize], codec)
|
|
129
|
+
warn_half_a_value_object
|
|
78
130
|
end
|
|
79
131
|
|
|
80
132
|
def cast(expr) = @cast&.call(expr)
|
|
@@ -95,21 +147,74 @@ class GraphWeaver::Codegen
|
|
|
95
147
|
|
|
96
148
|
# The code that normalizes a loose input — a Rails param — into this
|
|
97
149
|
# scalar's Ruby type before it is serialized, or nil for nothing to do.
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
150
|
+
# The Ruby type's own rule in Coerce is the check, which is what
|
|
151
|
+
# `.checked(:never)` on the generated sig gives up. A registration that
|
|
152
|
+
# named its own `cast:` says how to build the Ruby object instead — but
|
|
153
|
+
# the guard in front of it and the verdict when it refuses are still the
|
|
154
|
+
# library's, so both go through Coerce.cast rather than a bare `is_a?`
|
|
155
|
+
# (a DateTime is one of those, and is not a Date on any wire).
|
|
156
|
+
# The schema's name for the scalar travels with the value, so a refusal
|
|
157
|
+
# reports GraphQL vocabulary rather than the Ruby type it maps to —
|
|
158
|
+
# register_scalar("Money", BigDecimal) refuses a Money, not a BigDecimal.
|
|
102
159
|
def coerce_input(expr)
|
|
103
|
-
if
|
|
104
|
-
"
|
|
105
|
-
elsif
|
|
106
|
-
"GraphWeaver::Coerce
|
|
160
|
+
if (fn = coercer)
|
|
161
|
+
"GraphWeaver::Coerce.#{fn}(#{expr}, #{@graphql_name.inspect})"
|
|
162
|
+
elsif cast?
|
|
163
|
+
"GraphWeaver::Coerce.cast(#{@type}, #{expr}, #{@graphql_name.inspect}) { |raw| #{cast("raw")} }"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Why this registration can't read the JSON at `where`, and what to do about
|
|
168
|
+
# it. Two different mistakes land here, so say which: a type: given by name
|
|
169
|
+
# was never probed (there is no class in hand to probe), while a class was
|
|
170
|
+
# probed and matched nothing. Raised by Codegen#refuse_uncastable!, which
|
|
171
|
+
# owns the question of whether the wire could satisfy the prop at all.
|
|
172
|
+
def uncastable_message(where)
|
|
173
|
+
article = GraphWeaver::Internal::Util.article(@type)
|
|
174
|
+
head = "register_scalar(#{@graphql_name.inspect}, #{@klass ? @type : @type.inspect}) has no " \
|
|
175
|
+
"cast, so nothing builds #{article} #{@type} out of the JSON at #{where}"
|
|
176
|
+
if @klass.nil?
|
|
177
|
+
"#{head} — a type: given by name is never probed, since there is no class in hand. Pass " \
|
|
178
|
+
"the class (register_scalar(#{@graphql_name.inspect}, #{@type})) to infer a cast from " \
|
|
179
|
+
"it, or name one yourself (cast: :parse names a class method, " \
|
|
180
|
+
"cast: ->(v) { \"#{@type}.parse(\#{v})\" } emits any expression)"
|
|
181
|
+
else
|
|
182
|
+
"#{head} — #{@type} defines no .parse and no .load, and Kernel has no #{@type} conversion " \
|
|
183
|
+
"function, so there was nothing to infer. Give it a cast (cast: :parse names a class " \
|
|
184
|
+
"method, cast: ->(v) { \"#{@type}.new(\#{v})\" } emits any expression), or register a " \
|
|
185
|
+
"type the wire already parses into"
|
|
107
186
|
end
|
|
108
187
|
end
|
|
109
188
|
|
|
110
189
|
private
|
|
111
190
|
|
|
191
|
+
# A result compares its props with eql?, so that it and #hash agree on what
|
|
192
|
+
# "same" means. A type that leaves eql? at Object's compares by identity,
|
|
193
|
+
# so two results parsed from the same bytes are unequal and useless as hash
|
|
194
|
+
# keys — whether it defined == (the common Ruby idiom, and the leaf itself
|
|
195
|
+
# then compares fine) or no equality at all. Nothing here can fix that;
|
|
196
|
+
# only the type can.
|
|
197
|
+
def warn_half_a_value_object
|
|
198
|
+
# a Module type names a duck the gem never sees an instance of; a T::Enum's
|
|
199
|
+
# values are singletons, so identity already is equality
|
|
200
|
+
return unless @klass.is_a?(Class) && !(@klass < T::Enum) && !defines?(:eql?)
|
|
201
|
+
|
|
202
|
+
GraphWeaver::Internal::Log.log(:warn) do
|
|
203
|
+
"register_scalar(#{@graphql_name.inspect}, #{@type}): #{@type} inherits #eql? and #hash, " \
|
|
204
|
+
"so its instances compare by identity — two results parsed from the same response won't " \
|
|
205
|
+
"be equal and a result won't work as a hash key — define ==, eql? and hash off the same " \
|
|
206
|
+
"values (alias_method :eql?, :== is the usual shortcut)"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def defines?(method)
|
|
211
|
+
![BasicObject, Kernel, Object].include?(@klass.instance_method(method).owner)
|
|
212
|
+
end
|
|
213
|
+
|
|
112
214
|
def coercer
|
|
215
|
+
# a cast: the registration named is the whole rule (:itself asks for
|
|
216
|
+
# no rich object, which is not the same as asking for no check)
|
|
217
|
+
return unless @cast_given.nil?
|
|
113
218
|
return "id" if @graphql_name == "ID" && @type == "String"
|
|
114
219
|
|
|
115
220
|
COERCERS[@type]
|
|
@@ -126,45 +231,77 @@ class GraphWeaver::Codegen
|
|
|
126
231
|
end
|
|
127
232
|
end
|
|
128
233
|
|
|
234
|
+
# Kernel's conversion functions are how a wire value becomes one of these
|
|
235
|
+
# — BigDecimal defines neither .parse nor .load, but Kernel#BigDecimal has
|
|
236
|
+
# read a decimal string all along. Only for a type the wire can't already
|
|
237
|
+
# be: Kernel#String and Kernel#Array wrap a value rather than convert it.
|
|
238
|
+
def kernel_cast
|
|
239
|
+
return unless Kernel.private_method_defined?(@type.to_sym)
|
|
240
|
+
return if WIRE_CLASSES.any? { |native| native.name == @type }
|
|
241
|
+
|
|
242
|
+
->(type, expr) { "#{type}(#{expr})" }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# A Proc here builds SOURCE for the generated file, so a proc that
|
|
246
|
+
# converts a value (`->(v) { v.to_sym }`) can't work — it interpolates to
|
|
247
|
+
# nothing and every response fails far from the registration. Probe it
|
|
248
|
+
# once now, where the message can name the spelling.
|
|
249
|
+
def source_builder!(option, proc)
|
|
250
|
+
probe = proc.arity.zero? ? proc.call : proc.call("v")
|
|
251
|
+
return proc if probe.is_a?(String)
|
|
252
|
+
|
|
253
|
+
raise ArgumentError, "#{option}: a Proc must return the Ruby source to emit — " \
|
|
254
|
+
"e.g. #{option}: ->(v) { \"#{@type}.parse(\#{v})\" } — got #{probe.inspect}; " \
|
|
255
|
+
"a Symbol names a method instead (#{option}: :parse)"
|
|
256
|
+
rescue ArgumentError => e
|
|
257
|
+
raise if e.message.start_with?("#{option}:")
|
|
258
|
+
|
|
259
|
+
raise ArgumentError, "#{option}: a Proc takes one argument, the expression to wrap — #{e.message}"
|
|
260
|
+
end
|
|
261
|
+
|
|
129
262
|
# nil infers via the matched codec; :itself opts out (identity); a
|
|
130
263
|
# Symbol is a class method on the type — Money.parse(expr)
|
|
131
264
|
def normalize_cast(cast, inferred)
|
|
132
265
|
case cast
|
|
133
266
|
when :itself then nil
|
|
134
267
|
when nil then inferred && ->(expr) { inferred.call(@type, expr) }
|
|
135
|
-
when Proc then cast
|
|
268
|
+
when Proc then source_builder!(:cast, cast)
|
|
136
269
|
when Symbol then ->(expr) { "#{@type}.#{cast}(#{expr})" }
|
|
137
270
|
else raise ArgumentError, "cast: must be a Symbol, Proc, :itself, or nil, got #{cast.inspect}"
|
|
138
271
|
end
|
|
139
272
|
end
|
|
140
273
|
|
|
141
|
-
# nil infers via the matched codec; :itself opts out (identity); a
|
|
142
|
-
#
|
|
274
|
+
# nil infers via the matched codec; :itself opts out (identity); a Symbol
|
|
275
|
+
# is an instance method on the value — expr.to_s — and an Array is that
|
|
276
|
+
# method with arguments: [:to_s, "F"] => expr.to_s("F")
|
|
143
277
|
def normalize_serialize(serialize, inferred)
|
|
144
278
|
case serialize
|
|
145
279
|
when :itself then nil
|
|
146
280
|
when nil then inferred && ->(expr) { inferred.call(@type, expr) }
|
|
147
|
-
when Proc then serialize
|
|
281
|
+
when Proc then source_builder!(:serialize, serialize)
|
|
148
282
|
when Symbol then ->(expr) { "#{expr}.#{serialize}" }
|
|
149
|
-
|
|
283
|
+
when Array
|
|
284
|
+
method, *args = serialize
|
|
285
|
+
unless method.is_a?(Symbol)
|
|
286
|
+
# a syntax error in the generated file otherwise
|
|
287
|
+
raise ArgumentError, "serialize: an Array is [method, *arguments], got #{serialize.inspect}"
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
->(expr) { "#{expr}.#{method}(#{args.map(&:inspect).join(", ")})" }
|
|
291
|
+
else raise ArgumentError, "serialize: must be a Symbol, Array, Proc, :itself, or nil, got #{serialize.inspect}"
|
|
150
292
|
end
|
|
151
293
|
end
|
|
152
294
|
|
|
153
|
-
# The runnable half of normalize_serialize: a Symbol
|
|
154
|
-
#
|
|
155
|
-
# knows its own call, and a Proc emits code there
|
|
295
|
+
# The runnable half of normalize_serialize: a Symbol (or Symbol with
|
|
296
|
+
# arguments) names a method — :itself included, which is identity either
|
|
297
|
+
# way — an inferred codec knows its own call, and a Proc emits code there
|
|
298
|
+
# is no way to run.
|
|
156
299
|
def runtime_serialize(serialize, codec)
|
|
157
300
|
case serialize
|
|
158
|
-
when Symbol then ->(value) { value.public_send(serialize) }
|
|
301
|
+
when Symbol, Array then ->(value) { value.public_send(*serialize) }
|
|
159
302
|
when nil then codec && ->(value) { codec.call.call(@klass, value) }
|
|
160
303
|
end
|
|
161
304
|
end
|
|
162
|
-
|
|
163
|
-
# With only a type-name string we can't assume the lib is installed at
|
|
164
|
-
# codegen time, so the paths aren't loaded — only shape-checked.
|
|
165
|
-
def normalize_requires(requires)
|
|
166
|
-
GraphWeaver::Codegen.normalize_requires!(requires, load: !@klass.nil?)
|
|
167
|
-
end
|
|
168
305
|
end
|
|
169
306
|
|
|
170
307
|
class << self
|
|
@@ -190,7 +327,10 @@ class GraphWeaver::Codegen
|
|
|
190
327
|
end
|
|
191
328
|
end
|
|
192
329
|
end
|
|
330
|
+
end
|
|
193
331
|
|
|
332
|
+
# The scalar half of one graph's registrations — see Codegen::Registry.
|
|
333
|
+
class Registry
|
|
194
334
|
# Register (or override) how a GraphQL custom scalar deserializes into
|
|
195
335
|
# a Ruby object and serializes back onto the wire. See ScalarType for
|
|
196
336
|
# the accepted cast:/serialize:/requires: forms. Later registrations
|
|
@@ -230,36 +370,55 @@ class GraphWeaver::Codegen
|
|
|
230
370
|
self
|
|
231
371
|
end
|
|
232
372
|
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
240
|
-
#
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
373
|
+
# Pre-registered scalars — ordinary entries in the one registry, so a
|
|
374
|
+
# later register_scalar overrides any of them.
|
|
375
|
+
#
|
|
376
|
+
# The five the spec names stay pass-through: their Ruby classes (String,
|
|
377
|
+
# Integer) define neither .parse nor .load, so inference matches nothing
|
|
378
|
+
# and leaves them identity — which is exactly why we can name them with
|
|
379
|
+
# the real class constants. Float is the exception, and its rule lives in
|
|
380
|
+
# STDLIB with the others, so `register_scalar "Ratio", Float` reads the
|
|
381
|
+
# wire exactly as the built-in Float does.
|
|
382
|
+
#
|
|
383
|
+
# The rest are names, not guesses: graphql-ruby ships all but DateTime as
|
|
384
|
+
# its own scalars, and this library runs a graphql-ruby schema in-process.
|
|
385
|
+
# DateTime is what GitHub, Shopify and most hand-written schemas call an
|
|
386
|
+
# ISO 8601 timestamp; a schema that means something else by it fails
|
|
387
|
+
# loudly (the cast raises, naming the field) and is one register_scalar
|
|
388
|
+
# away. Date and datetime are told apart by their Ruby type — a Date cast
|
|
389
|
+
# to Time would invent a midnight the server never sent.
|
|
244
390
|
def register_builtin_scalars!
|
|
245
391
|
register_scalar "ID", String
|
|
246
392
|
register_scalar "String", String
|
|
247
393
|
register_scalar "Int", Integer
|
|
248
|
-
register_scalar "Float", Float
|
|
394
|
+
register_scalar "Float", Float
|
|
249
395
|
register_scalar "Boolean", "T::Boolean"
|
|
250
|
-
register_scalar "Date", Date
|
|
396
|
+
register_scalar "Date", Date
|
|
397
|
+
register_scalar "ISO8601Date", Date
|
|
398
|
+
register_scalar "ISO8601DateTime", Time
|
|
399
|
+
register_scalar "DateTime", Time
|
|
400
|
+
# graphql-ruby writes a BigInt as a string, since JSON numbers stop
|
|
401
|
+
# being exact at 2^53 — so read either spelling and write the one the
|
|
402
|
+
# server does.
|
|
403
|
+
register_scalar "BigInt", Integer,
|
|
404
|
+
cast: ->(expr) { "GraphWeaver::Coerce.integer(#{expr})" }, serialize: :to_s
|
|
405
|
+
# untyped on purpose: registering it says so, rather than leaving JSON
|
|
406
|
+
# in the "unregistered custom scalars" report every generation
|
|
407
|
+
register_scalar "JSON", "T.untyped"
|
|
408
|
+
# the objects, not the names: a later register_scalar("DateTime", ...)
|
|
409
|
+
# replaces the entry, and that is app intent rather than a pre-registration
|
|
410
|
+
@builtin_entries = scalar_registry.values.freeze
|
|
251
411
|
end
|
|
252
412
|
private :register_builtin_scalars!
|
|
413
|
+
|
|
414
|
+
# Whether this name still holds the entry pre-registration put there.
|
|
415
|
+
def builtin_scalar?(name)
|
|
416
|
+
entry = scalar_registry[name]
|
|
417
|
+
@builtin_entries.any? { |builtin| builtin.equal?(entry) }
|
|
418
|
+
end
|
|
253
419
|
end
|
|
254
420
|
|
|
255
421
|
# codegen's own record of a registration; users get one back from
|
|
256
422
|
# `.scalar` but never name the class
|
|
257
423
|
private_constant :ScalarType
|
|
258
|
-
|
|
259
|
-
register_builtin_scalars!
|
|
260
|
-
|
|
261
|
-
# Pre-registered rather than user intent, so generation doesn't hold a schema
|
|
262
|
-
# to them (validate_registration! skips these). Read off the registry the line
|
|
263
|
-
# above just filled: a seventh built-in shouldn't have to be named twice.
|
|
264
|
-
BUILTIN_SCALARS = scalar_registry.keys.freeze
|
|
265
424
|
end
|