graph_weaver 0.6.1 → 0.7.1

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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +8 -0
  3. data/Gemfile.lock +153 -4
  4. data/README.md +45 -79
  5. data/docs/alternatives.md +195 -0
  6. data/docs/cassettes.md +61 -50
  7. data/docs/editors.md +32 -47
  8. data/docs/errors.md +360 -103
  9. data/docs/federation.md +692 -473
  10. data/docs/generated_modules.md +441 -314
  11. data/docs/getting_started.md +370 -194
  12. data/docs/i18n.md +171 -0
  13. data/docs/logging.md +197 -50
  14. data/docs/real_world.md +42 -27
  15. data/docs/scalars.md +307 -176
  16. data/docs/testing.md +473 -220
  17. data/docs/transports.md +224 -151
  18. data/docs/upgrading.md +258 -305
  19. data/examples/README.md +38 -0
  20. data/examples/countries.rb +39 -0
  21. data/examples/federation.rb +62 -0
  22. data/examples/github/generate.rb +20 -0
  23. data/examples/github/generated/star_mutation.rb +126 -0
  24. data/examples/github/generated/stargazers_query.rb +232 -0
  25. data/examples/github/generated/starred_query.rb +151 -0
  26. data/examples/github/queries/star.graphql +8 -0
  27. data/examples/github/queries/stargazers.graphql +22 -0
  28. data/examples/github/queries/starred.graphql +11 -0
  29. data/examples/github/run.rb +43 -0
  30. data/examples/github/setup.rb +18 -0
  31. data/examples/rick_and_morty.rb +57 -0
  32. data/graph_weaver.gemspec +19 -3
  33. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  34. data/lib/graph_weaver/client.rb +69 -11
  35. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  36. data/lib/graph_weaver/codegen/emit.rb +98 -29
  37. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  38. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  39. data/lib/graph_weaver/codegen/registry.rb +175 -0
  40. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  41. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  42. data/lib/graph_weaver/codegen.rb +406 -195
  43. data/lib/graph_weaver/coerce.rb +155 -26
  44. data/lib/graph_weaver/context_seam.rb +54 -0
  45. data/lib/graph_weaver/errors.rb +284 -46
  46. data/lib/graph_weaver/federation.rb +129 -27
  47. data/lib/graph_weaver/graph.rb +315 -0
  48. data/lib/graph_weaver/hints.rb +100 -24
  49. data/lib/graph_weaver/in_process.rb +27 -15
  50. data/lib/graph_weaver/input_struct.rb +119 -32
  51. data/lib/graph_weaver/internal/endpoint.rb +80 -0
  52. data/lib/graph_weaver/internal/headers.rb +70 -0
  53. data/lib/graph_weaver/internal/overrides.rb +67 -5
  54. data/lib/graph_weaver/internal/planner.rb +45 -15
  55. data/lib/graph_weaver/internal/refusal.rb +49 -0
  56. data/lib/graph_weaver/internal/schemas.rb +23 -9
  57. data/lib/graph_weaver/internal/selection.rb +34 -0
  58. data/lib/graph_weaver/internal/server_input.rb +251 -0
  59. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  60. data/lib/graph_weaver/internal/unused.rb +287 -0
  61. data/lib/graph_weaver/internal/values.rb +40 -4
  62. data/lib/graph_weaver/internal.rb +249 -14
  63. data/lib/graph_weaver/log_subscriber.rb +74 -0
  64. data/lib/graph_weaver/logging.rb +163 -19
  65. data/lib/graph_weaver/query_module.rb +44 -3
  66. data/lib/graph_weaver/railtie.rb +237 -17
  67. data/lib/graph_weaver/representation.rb +55 -17
  68. data/lib/graph_weaver/result_struct.rb +90 -0
  69. data/lib/graph_weaver/retry.rb +45 -13
  70. data/lib/graph_weaver/rspec.rb +404 -93
  71. data/lib/graph_weaver/schema_loader.rb +266 -56
  72. data/lib/graph_weaver/tasks.rb +380 -89
  73. data/lib/graph_weaver/testing/cassette.rb +34 -10
  74. data/lib/graph_weaver/testing/endpoint.rb +107 -0
  75. data/lib/graph_weaver/testing/failure.rb +69 -12
  76. data/lib/graph_weaver/testing/fake_client.rb +164 -45
  77. data/lib/graph_weaver/testing/router.rb +64 -13
  78. data/lib/graph_weaver/testing.rb +200 -58
  79. data/lib/graph_weaver/transport/faraday.rb +41 -8
  80. data/lib/graph_weaver/transport/http.rb +48 -6
  81. data/lib/graph_weaver/transport.rb +134 -27
  82. data/lib/graph_weaver/version.rb +1 -1
  83. data/lib/graph_weaver.rb +495 -106
  84. metadata +71 -3
  85. data/CHANGELOG.md +0 -2355
@@ -52,15 +52,17 @@ class GraphWeaver::Codegen
52
52
  ->(klass, value) { klass.dump(value) }),
53
53
  ].freeze
54
54
 
55
- # A scalar with no `cast:` to run input through: its Ruby type is the
56
- # whole rule, so key on that — a custom scalar registered as a plain
57
- # String gets the same check. ID is the exception GraphQL itself names
58
- # (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.
59
59
  COERCERS = {
60
60
  "Integer" => "integer",
61
61
  "Float" => "float",
62
62
  "String" => "string",
63
63
  "T::Boolean" => "boolean",
64
+ "Date" => "date",
65
+ "Time" => "time",
64
66
  }.freeze
65
67
 
66
68
  # What the library already knows about a Ruby type, so registering one
@@ -72,11 +74,25 @@ class GraphWeaver::Codegen
72
74
  # more than the ISO 8601 a Date scalar carries.
73
75
  # - the file to require, so the generated source stands alone.
74
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
+
75
84
  STDLIB = {
76
85
  "BigDecimal" => { serialize: [:to_s, "F"], requires: "bigdecimal" },
77
- "Date" => { cast: :iso8601, serialize: :iso8601, requires: "date" },
78
- "Time" => { cast: :parse, serialize: :iso8601, requires: "time" },
79
- "DateTime" => { cast: :iso8601, serialize: :iso8601, requires: "date" },
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" },
80
96
  }.freeze
81
97
 
82
98
  # Everything JSON.parse can hand back. A registered type outside this set
@@ -104,10 +120,13 @@ class GraphWeaver::Codegen
104
120
  else
105
121
  GraphWeaver::Codegen.normalize_requires!(requires, load: !@klass.nil?)
106
122
  end
123
+ @cast_given = cast unless cast == :itself
107
124
  codec = @klass && CODECS.find { |c| @klass.respond_to?(c.probe) }
108
125
  @cast = normalize_cast(cast || known[:cast], codec&.cast || kernel_cast)
109
126
  @serialize = normalize_serialize(serialize || known[:serialize], codec&.serialize)
110
- @serialize_value = runtime_serialize(serialize || known[:serialize], codec)
127
+ @serialize_value = (known[:call] if serialize.nil?) ||
128
+ runtime_serialize(serialize || known[:serialize], codec)
129
+ warn_half_a_value_object
111
130
  end
112
131
 
113
132
  def cast(expr) = @cast&.call(expr)
@@ -128,21 +147,74 @@ class GraphWeaver::Codegen
128
147
 
129
148
  # The code that normalizes a loose input — a Rails param — into this
130
149
  # scalar's Ruby type before it is serialized, or nil for nothing to do.
131
- # `cast:` is the how: it already knows how to build the Ruby object from
132
- # a wire value, guarded so an already-typed value passes through. A
133
- # scalar without one falls back to its Ruby type's check, which is what
134
- # `.checked(:never)` on the generated sig gives up.
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.
135
159
  def coerce_input(expr)
136
- if cast?
137
- "(#{expr}.is_a?(#{@type}) ? #{expr} : #{cast(expr)})"
138
- elsif (fn = coercer)
139
- "GraphWeaver::Coerce.#{fn}(#{expr})"
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"
140
186
  end
141
187
  end
142
188
 
143
189
  private
144
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
+
145
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?
146
218
  return "id" if @graphql_name == "ID" && @type == "String"
147
219
 
148
220
  COERCERS[@type]
@@ -170,13 +242,30 @@ class GraphWeaver::Codegen
170
242
  ->(type, expr) { "#{type}(#{expr})" }
171
243
  end
172
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
+
173
262
  # nil infers via the matched codec; :itself opts out (identity); a
174
263
  # Symbol is a class method on the type — Money.parse(expr)
175
264
  def normalize_cast(cast, inferred)
176
265
  case cast
177
266
  when :itself then nil
178
267
  when nil then inferred && ->(expr) { inferred.call(@type, expr) }
179
- when Proc then cast
268
+ when Proc then source_builder!(:cast, cast)
180
269
  when Symbol then ->(expr) { "#{@type}.#{cast}(#{expr})" }
181
270
  else raise ArgumentError, "cast: must be a Symbol, Proc, :itself, or nil, got #{cast.inspect}"
182
271
  end
@@ -189,7 +278,7 @@ class GraphWeaver::Codegen
189
278
  case serialize
190
279
  when :itself then nil
191
280
  when nil then inferred && ->(expr) { inferred.call(@type, expr) }
192
- when Proc then serialize
281
+ when Proc then source_builder!(:serialize, serialize)
193
282
  when Symbol then ->(expr) { "#{expr}.#{serialize}" }
194
283
  when Array
195
284
  method, *args = serialize
@@ -238,7 +327,10 @@ class GraphWeaver::Codegen
238
327
  end
239
328
  end
240
329
  end
330
+ end
241
331
 
332
+ # The scalar half of one graph's registrations — see Codegen::Registry.
333
+ class Registry
242
334
  # Register (or override) how a GraphQL custom scalar deserializes into
243
335
  # a Ruby object and serializes back onto the wire. See ScalarType for
244
336
  # the accepted cast:/serialize:/requires: forms. Later registrations
@@ -284,10 +376,9 @@ class GraphWeaver::Codegen
284
376
  # The five the spec names stay pass-through: their Ruby classes (String,
285
377
  # Integer) define neither .parse nor .load, so inference matches nothing
286
378
  # and leaves them identity — which is exactly why we can name them with
287
- # the real class constants. Float is the exception: JSON has one number
288
- # type, so a whole Float arrives as `1` from every encoder that drops the
289
- # trailing zero (graphql-js and Go both do), and Coerce.float widens that
290
- # without accepting the garbage `.to_f` would silently turn into 0.0.
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.
291
382
  #
292
383
  # The rest are names, not guesses: graphql-ruby ships all but DateTime as
293
384
  # its own scalars, and this library runs a graphql-ruby schema in-process.
@@ -300,7 +391,7 @@ class GraphWeaver::Codegen
300
391
  register_scalar "ID", String
301
392
  register_scalar "String", String
302
393
  register_scalar "Int", Integer
303
- register_scalar "Float", Float, cast: ->(expr) { "GraphWeaver::Coerce.float(#{expr})" }
394
+ register_scalar "Float", Float
304
395
  register_scalar "Boolean", "T::Boolean"
305
396
  register_scalar "Date", Date
306
397
  register_scalar "ISO8601Date", Date
@@ -314,18 +405,20 @@ class GraphWeaver::Codegen
314
405
  # untyped on purpose: registering it says so, rather than leaving JSON
315
406
  # in the "unregistered custom scalars" report every generation
316
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
317
411
  end
318
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
319
419
  end
320
420
 
321
421
  # codegen's own record of a registration; users get one back from
322
422
  # `.scalar` but never name the class
323
423
  private_constant :ScalarType
324
-
325
- register_builtin_scalars!
326
-
327
- # Pre-registered rather than user intent, so generation doesn't hold a schema
328
- # to them (validate_registration! skips these). Read off the registry the line
329
- # above just filled: a seventh built-in shouldn't have to be named twice.
330
- BUILTIN_SCALARS = scalar_registry.keys.freeze
331
424
  end
@@ -11,16 +11,18 @@
11
11
  # path is resolved against an actual selection).
12
12
 
13
13
  class GraphWeaver::Codegen
14
- class << self
14
+ # The extend_type half of one graph's registrations — see Codegen::Registry.
15
+ class Registry
15
16
  # Attach app-owned helper modules to every struct generated from a
16
17
  # GraphQL type — the logic stays in your code, generation wires it in:
17
18
  #
18
19
  # GraphWeaver.extend_type("Pet", PetHelpers)
19
20
  #
20
21
  # Or build the mixin inline — the block is module_eval'd into a fresh
21
- # module auto-named GraphWeaver::TypeHelpers::<Type>. Handy for quick
22
- # decoration; srb tc can't see into block-defined methods, so prefer
23
- # a named module where static checking matters:
22
+ # module named for where it is written and what it extends:
23
+ # GraphWeaver::TypeHelpers::Pet, or ::Billing::Pet in graph :billing.
24
+ # Handy for quick decoration; srb tc can't see into block-defined methods,
25
+ # so prefer a named module where static checking matters:
24
26
  #
25
27
  # GraphWeaver.extend_type("Pet") do
26
28
  # def display_name = "#{name} the pet"
@@ -118,19 +120,62 @@ class GraphWeaver::Codegen
118
120
  # generated files may still name them.
119
121
  def reset_type_helpers!
120
122
  type_registry.clear
123
+ helper_counts.clear
121
124
  self
122
125
  end
123
126
 
124
- # a block-built mixin needs a name generated files can reference:
125
- # GraphWeaver::TypeHelpers::Pet (suffixed on re-registration)
127
+ # Which graph's registrations this registry holds nil for the top-level
128
+ # one. Block-built helpers are named for it, so two graphs extending the
129
+ # same type get two constants (see helper_module).
130
+ attr_accessor :graph_name
131
+
132
+ # A block-built mixin needs a name generated files can reference:
133
+ # GraphWeaver::TypeHelpers::Pet, or ::Billing::Pet in graph :billing (V2,
134
+ # V3… for a second and third block on the same type in the same place).
135
+ #
136
+ # The name is a function of the source and nothing else — where the block
137
+ # is written and what it extends. It gets baked into generated code, so
138
+ # naming it after whichever constants happened to exist made it a function
139
+ # of how many times THIS process had read the registry, and `generate`
140
+ # wrote a name a plain boot never creates.
126
141
  def helper_module(graphql_name, block)
127
- base = GraphWeaver::Inflect.camelize(graphql_name.to_s)
128
- name = base
129
- count = 1
130
- name = "#{base}V#{count += 1}" while GraphWeaver::TypeHelpers.const_defined?(name, false)
131
- GraphWeaver::TypeHelpers.const_set(name, Module.new(&block))
142
+ namespace = helper_namespace
143
+ type = GraphWeaver::Inflect.camelize(graphql_name.to_s)
144
+ index = (helper_counts[[namespace.name, type]] += 1)
145
+ name = index == 1 ? type : "#{type}V#{index}"
146
+ # reused rather than replaced, so re-declaring the same source (a Rails
147
+ # to_prepare reload) keeps the module already-loaded structs include
148
+ mod = const_under(namespace, name) { Module.new }
149
+ mod.module_eval(&block)
150
+ mod
132
151
  end
133
152
  private :helper_module
153
+
154
+ # Where this registry's block-built helpers live: under a module named for
155
+ # the graph, so two graphs extending the same type get two constants and
156
+ # neither has to know the other exists.
157
+ def helper_namespace
158
+ return GraphWeaver::TypeHelpers unless graph_name
159
+
160
+ const_under(GraphWeaver::TypeHelpers, GraphWeaver::Inflect.camelize(graph_name.to_s)) { Module.new }
161
+ end
162
+ private :helper_namespace
163
+
164
+ def const_under(namespace, name)
165
+ return namespace.const_get(name, false) if namespace.const_defined?(name, false)
166
+
167
+ namespace.const_set(name, yield)
168
+ end
169
+ private :const_under
170
+
171
+ # How many block-built helpers this registry has already named for a type.
172
+ # Per registry, not per process: a graph's registrations are replayed over
173
+ # a fresh copy of the top-level registry on every read, so the same source
174
+ # counts the same way every time.
175
+ def helper_counts
176
+ @helper_counts ||= Hash.new(0)
177
+ end
178
+ private :helper_counts
134
179
  end
135
180
  end
136
181