graph_weaver 0.4.4 → 0.5.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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1357 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -136
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -251
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -98
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +617 -264
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +12 -6
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +21 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +15 -1
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +820 -57
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +59 -7
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +186 -62
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +194 -28
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +31 -6
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +74 -18
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +398 -170
  62. metadata +20 -3
@@ -4,11 +4,13 @@
4
4
  require "graphql"
5
5
  require "json"
6
6
 
7
+ require_relative "../parsing"
8
+
7
9
  # A fake client that fabricates schema-correct responses for whatever query
8
10
  # arrives — the zero-setup way to test code built on generated modules:
9
11
  #
10
- # fake = GraphWeaver::Testing::FakeClient.new(schema:)
11
- # result = PersonQuery.execute!(id: "1", fake — positionally)
12
+ # fake = GraphWeaver::Testing::FakeClient.new
13
+ # result = PersonQuery.execute!(client: fake, id: "1")
12
14
  # result.person.name # => a plausible String, typed and castable
13
15
  #
14
16
  # Values are type-correct by construction (real enum values, valid
@@ -18,15 +20,33 @@ require "json"
18
20
  #
19
21
  # overrides: pin fields by GraphQL name — schema vocabulary, so keys
20
22
  # survive query refactors. "Type.field" beats "field"; values are
21
- # literals or zero-arg procs. (An override with a wrong-typed value is
22
- # also the way to simulate a corrupt payload casting raises
23
- # GraphWeaver::TypeError.)
23
+ # literals or zero-arg procs. Keys are checked against the schema, since
24
+ # a typo'd one would pin nothing and leave the test green. (An override
25
+ # with a wrong-typed value is also the way to simulate a corrupt
26
+ # payload — casting raises GraphWeaver::TypeError.)
24
27
  #
25
28
  # FakeClient.new(schema:, overrides: {
26
29
  # "Person.name" => "Daniel",
27
30
  # "email" => -> { "test@example.com" },
28
31
  # })
29
32
  #
33
+ # An override pins a whole subtree as readily as a leaf, and **merges**
34
+ # rather than replaces: name the fields the test is about and the rest of
35
+ # the selection is still fabricated. A list pins its own length, so "two
36
+ # orders, the first one paid" is the literal thing you write:
37
+ #
38
+ # FakeClient.new(schema:, overrides: {
39
+ # "Reader.name" => "Ada",
40
+ # "Reader.orders" => [{ "status" => "PAID" }, {}],
41
+ # })
42
+ #
43
+ # Keys inside a pinned subtree are response keys — what comes back on the
44
+ # wire, aliases included — and one the query doesn't select is refused,
45
+ # for the same reason a typo'd coordinate is.
46
+ #
47
+ # requests: every execute, in order ({ query:, variables:, operation_name: })
48
+ # — "did we send the right variables", and "did we call it at all".
49
+ #
30
50
  # Partial failures: fail_at simulates a field-level error with
31
51
  # spec-correct null propagation — the field's error lands in the errors
32
52
  # array (with its concrete path), the field becomes null, and nulls
@@ -45,19 +65,39 @@ require "json"
45
65
  #
46
66
  # FakeClient.new(schema:, corrupt: "Person.birthday")
47
67
  #
48
- # seed: makes a run reproducible (also seeds faker). Per-instance options
49
- # fall back to GraphWeaver::Testing.config.
68
+ # seed: makes a run reproducible (also seeds faker). Every option,
69
+ # schema: included, falls back to GraphWeaver::Testing.config — and the
70
+ # config's schema falls back to the committed dump.
50
71
  class GraphWeaver::Testing::FakeClient
72
+ include GraphWeaver::Parsing
51
73
  include GraphWeaver::Selection
52
74
 
53
75
  # sentinel: a simulated failure bubbling up to the nearest nullable spot
54
76
  NULL_BUBBLE = Object.new.freeze
55
77
 
56
- def initialize(schema:, overrides: {}, seed: nil, mode: nil, list_size: nil, null_chance: nil,
78
+ # sentinel: no override here distinct from an override OF nil, which
79
+ # pins the field null
80
+ UNPINNED = Object.new.freeze
81
+ private_constant :UNPINNED
82
+
83
+ # the schema responses are fabricated against — the way to reach it from
84
+ # a graphql: :fake spec, where GraphWeaver.client is one of these
85
+ attr_reader :schema
86
+
87
+ # Every execute, in order: { query:, variables:, operation_name: }.
88
+ #
89
+ # expect(fake.requests.size).to eq 1 # memoized, then
90
+ # expect(fake.requests.last[:variables]).to eq({ "id" => "1" })
91
+ attr_reader :requests
92
+
93
+ def initialize(schema: nil, overrides: {}, seed: nil, mode: nil, list_size: nil, null_chance: nil,
57
94
  errors: nil, fail_at: nil, corrupt: nil)
58
95
  config = GraphWeaver::Testing.config
59
- @schema = schema
96
+ @schema = schema || config.schema || raise(GraphWeaver::Error,
97
+ "no schema to fake against — set GraphWeaver::Testing.config.schema, pass schema:, " \
98
+ "or commit a schema dump at #{GraphWeaver.schema_path}")
60
99
  @overrides = config.overrides.merge(overrides)
100
+ GraphWeaver::Testing.validate_overrides!(@schema, @overrides)
61
101
  @values = GraphWeaver::Testing::Values.new(seed:, mode:)
62
102
  @list_size = list_size || config.list_size
63
103
  @null_chance = null_chance || config.null_chance
@@ -65,14 +105,32 @@ class GraphWeaver::Testing::FakeClient
65
105
  @extra_errors = wrap(errors).map { |error| normalize_error(error) }
66
106
  @fail_at = wrap(fail_at).map { |spec| normalize_fail_spec(spec) }
67
107
  @corrupt = wrap(corrupt)
108
+ @requests = []
68
109
  end
69
110
 
70
- def execute(query, variables: {})
111
+ # operation_name: is accepted for contract parity and ignored — one
112
+ # document holds one operation, so there is nothing to select between
113
+ # (see Selection#load_operation).
114
+ def execute(query, variables: {}, operation_name: nil)
115
+ # recorded before validation: "we never called it" and "we called it with
116
+ # a query that doesn't compile" are different failures
117
+ @requests << { query:, variables:, operation_name: }.freeze
118
+
119
+ # Validate first, as the other clients in the slot do: a field the schema
120
+ # doesn't have would otherwise walk into `get_field(...).type` on nil, and
121
+ # a NoMethodError from inside the fabricator is undiagnosable next to the
122
+ # "Did you mean" a real server gives. This is the commonest mistake there
123
+ # is — a query drifting ahead of the dump, or a typo in an ad-hoc one.
124
+ invalid = @schema.validate(GraphQL.parse(query))
125
+ return { "data" => nil, "errors" => invalid.map(&:to_h) } if invalid.any?
126
+
71
127
  operation = load_operation(query)
72
128
  root_type = operation_root_type(operation)
73
129
 
74
130
  @path = []
75
131
  @failures = []
132
+ # fail_at fires once per execute, not once per client lifetime
133
+ @fail_at.each { |spec| spec.delete("triggered") }
76
134
  data = object_value(root_type, operation.selections)
77
135
  data = nil if data.equal?(NULL_BUBBLE) # total propagation, like a real server
78
136
 
@@ -82,6 +140,27 @@ class GraphWeaver::Testing::FakeClient
82
140
  response
83
141
  end
84
142
 
143
+ # One fabricated object of `type_name` for these selections — the seam
144
+ # {FakeSubgraph} answers a federation `_entities` fetch through, where the
145
+ # representation names the type and the document only ever reached it
146
+ # through an inline fragment.
147
+ def object(type_name, selections, fragments: {})
148
+ type = @schema.get_type(type_name) or
149
+ raise GraphWeaver::Error, "#{type_name} is not a type of this schema"
150
+
151
+ @fragments = fragments
152
+ @path = []
153
+ @failures = []
154
+ value = object_value(type, selections)
155
+ value.equal?(NULL_BUBBLE) ? nil : value
156
+ end
157
+
158
+ # The Selection mixin's walk is machinery, not interface: a developer who
159
+ # types `.methods` on a fake should find execute, schema, requests, object
160
+ # and parse, not the twelve steps behind them.
161
+ private :load_operation, :operation_root_type, :each_field, :gather,
162
+ :gather_conditional, :applies?, :conditional?
163
+
85
164
  private
86
165
 
87
166
  def rng = @values.rng
@@ -102,11 +181,24 @@ class GraphWeaver::Testing::FakeClient
102
181
  spec.is_a?(String) ? { "path" => spec } : JSON.parse(JSON.generate(spec))
103
182
  end
104
183
 
105
- def object_value(type, selections)
184
+ # pins: the response keys an override pinned at this object, merged in as
185
+ # the walk reaches them — everything it doesn't name is fabricated.
186
+ def object_value(type, selections, pins: nil, source: nil)
187
+ # gather (not each_field) so a field selected twice — `a { x } a { y }` —
188
+ # fabricates the MERGED shape codegen's struct expects, not last-writer-wins
189
+ fields = gather(type, selections)
190
+ check_pins!(fields, pins, source) if pins
191
+
106
192
  result = {}
107
- each_field(type, selections) do |key, node|
193
+ fields.each do |key, nodes|
194
+ node = nodes.first
195
+ pin = (pins && pins.key?(key)) ? pins[key] : UNPINNED
108
196
  @path.push(key)
109
- value = node.name == "__typename" ? type.graphql_name : field_value(type, node)
197
+ value = if node.name == "__typename"
198
+ type.graphql_name
199
+ else
200
+ field_value(type, node, nodes.flat_map(&:selections), pin, source)
201
+ end
110
202
  @path.pop
111
203
 
112
204
  if value.equal?(NULL_BUBBLE)
@@ -127,7 +219,7 @@ class GraphWeaver::Testing::FakeClient
127
219
  @schema.get_field(type.graphql_name, node.name).type.kind.name == "NON_NULL"
128
220
  end
129
221
 
130
- def field_value(parent_type, node)
222
+ def field_value(parent_type, node, selections, pin = UNPINNED, source = nil)
131
223
  if (spec = matching_failure)
132
224
  @failures << {
133
225
  "message" => spec["message"] || "simulated failure",
@@ -138,19 +230,92 @@ class GraphWeaver::Testing::FakeClient
138
230
  return NULL_BUBBLE
139
231
  end
140
232
 
141
- override = @overrides.fetch("#{parent_type.graphql_name}.#{node.name}") do
142
- @overrides[node.name]
233
+ coordinate = "#{parent_type.graphql_name}.#{node.name}"
234
+ if pin.equal?(UNPINNED)
235
+ # most specific key wins; #fetch (not #[]) so an override OF nil pins null
236
+ pin = @overrides.fetch(coordinate) { @overrides.fetch(node.name, UNPINNED) }
237
+ source = @overrides.key?(coordinate) ? coordinate : node.name unless pin.equal?(UNPINNED)
143
238
  end
144
- return override.is_a?(Proc) ? override.call : override unless override.nil?
145
239
 
146
240
  field_type = @schema.get_field(parent_type.graphql_name, node.name).type
147
- if @corrupt.include?("#{parent_type.graphql_name}.#{node.name}")
148
- return corrupt_value(field_type)
241
+ return pinned_value(field_type, node, selections, pin, source) unless pin.equal?(UNPINNED)
242
+ return corrupt_value(field_type) if @corrupt.include?(coordinate)
243
+
244
+ type_value(field_type, node, selections)
245
+ end
246
+
247
+ # What an override pins here. A leaf takes the value outright; a composite
248
+ # MERGES — the keys it names are pinned and the rest of the selection is
249
+ # fabricated, so pinning one nested field never means hand-writing the
250
+ # subtree around it. A pinned list is exactly as long as it is written.
251
+ def pinned_value(type, node, selections, value, source)
252
+ value = value.call if value.is_a?(Proc)
253
+
254
+ case type.kind.name
255
+ when "NON_NULL" then pinned_value(type.of_type, node, selections, value, source)
256
+ when "LIST"
257
+ return value unless value.is_a?(Array)
258
+
259
+ value.each_with_index.map do |element, index|
260
+ @path.push(index)
261
+ begin
262
+ pinned_value(type.of_type, node, selections, element, source)
263
+ ensure
264
+ @path.pop
265
+ end
266
+ end
267
+ when "OBJECT", "UNION", "INTERFACE"
268
+ return value unless value.is_a?(Hash)
269
+
270
+ object_value(pinned_type(type, value, source), selections, pins: value, source:)
271
+ else
272
+ value
273
+ end
274
+ end
275
+
276
+ # The concrete type a pinned object is fabricated as. At a union or
277
+ # interface the pin has to say: picking a member at random would fabricate
278
+ # a shape the pinned keys don't fit, in whichever fraction of runs the
279
+ # seed lands there.
280
+ def pinned_type(type, value, source)
281
+ named = value["__typename"]
282
+ return type if named == type.graphql_name
283
+
284
+ members = (type.kind.name == "OBJECT") ? [type] : @schema.possible_types(type)
285
+ if named.nil?
286
+ return type if members.one?
287
+
288
+ raise GraphWeaver::Error, "override #{source.inspect} pins an object at #{location}, where " \
289
+ "the query can return #{members.map(&:graphql_name).sort.join(" or ")} — name the one you " \
290
+ "mean with \"__typename\"."
149
291
  end
150
292
 
151
- type_value(field_type, node)
293
+ found = members.find { |member| member.graphql_name == named }
294
+ return found if found
295
+
296
+ raise GraphWeaver::Error, "override #{source.inspect} pins __typename #{named.inspect} at " \
297
+ "#{location}, where the query can only return #{members.map(&:graphql_name).sort.join(" or ")}"
298
+ end
299
+
300
+ # A pinned key the query doesn't select would fabricate the field anyway
301
+ # and quietly leave the pin unread — the same silent-green failure a typo'd
302
+ # coordinate is validated against, one level down.
303
+ def check_pins!(fields, pins, source)
304
+ # __typename in a pin names the type to fabricate (see #pinned_type); it
305
+ # is an instruction, not a field the query has to have selected
306
+ unknown = pins.keys.reject { |key| key == "__typename" || fields.key?(key) }
307
+ return if unknown.empty?
308
+
309
+ suggestion = GraphWeaver.did_you_mean(fields.keys, unknown.first.to_s)
310
+ hint = suggestion ? " — did you mean #{suggestion.inspect}?" : "."
311
+ raise GraphWeaver::Error, "override #{source.inspect} supplies #{unknown.first.inspect} at " \
312
+ "#{location}, which this query doesn't select#{hint} An override's keys are response keys, " \
313
+ "exactly as they arrive on the wire (#{fields.keys.join(", ")})."
152
314
  end
153
315
 
316
+ # where the walk is, for a message: "reader.orders.0"
317
+ def location = @path.empty? ? "the root" : @path.join(".")
318
+
154
319
  # a value casting can't accept, derived from the field's own type — and
155
320
  # wrapped per list layer so the corruption lands on the element cast
156
321
  def corrupt_value(type)
@@ -181,17 +346,18 @@ class GraphWeaver::Testing::FakeClient
181
346
  argument = node.arguments.find { |arg| %w[first last limit].include?(arg.name) }
182
347
  return argument.value if argument && argument.value.is_a?(Integer)
183
348
 
184
- rng.rand(@list_size)
349
+ # an Integer list_size means exactly that many; a Range randomizes within it
350
+ @list_size.is_a?(Range) ? rng.rand(@list_size) : @list_size
185
351
  end
186
352
 
187
- def type_value(type, node, non_null: false)
353
+ def type_value(type, node, selections, non_null: false)
188
354
  case type.kind.name
189
355
  when "NON_NULL"
190
- type_value(type.of_type, node, non_null: true)
356
+ type_value(type.of_type, node, selections, non_null: true)
191
357
  when "LIST"
192
358
  elements = Array.new(list_length(node)) do |index|
193
359
  @path.push(index)
194
- element = type_value(type.of_type, node)
360
+ element = type_value(type.of_type, node, selections)
195
361
  @path.pop
196
362
  element
197
363
  end
@@ -206,21 +372,21 @@ class GraphWeaver::Testing::FakeClient
206
372
  else
207
373
  return if !non_null && rng.rand < @null_chance
208
374
 
209
- core_value(type, node)
375
+ core_value(type, node, selections)
210
376
  end
211
377
  end
212
378
 
213
- def core_value(type, node)
379
+ def core_value(type, node, selections)
214
380
  case type.kind.name
215
381
  when "SCALAR"
216
382
  @values.scalar(type.graphql_name, node.name)
217
383
  when "ENUM"
218
384
  type.values.keys.sort.sample(random: rng)
219
385
  when "OBJECT"
220
- object_value(type, node.selections)
386
+ object_value(type, selections)
221
387
  when "UNION", "INTERFACE"
222
388
  member = @schema.possible_types(type).sort_by(&:graphql_name).sample(random: rng)
223
- object_value(member, node.selections)
389
+ object_value(member, selections)
224
390
  else
225
391
  raise NotImplementedError, "cannot fake kind: #{type.kind.name}"
226
392
  end
@@ -0,0 +1,85 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "graphql"
5
+
6
+ require_relative "fake_client"
7
+
8
+ module GraphWeaver
9
+ module Testing
10
+ # A subgraph this process doesn't serve, answered with fabricated data.
11
+ #
12
+ # Composed into the supergraph but routed to another service, a subgraph
13
+ # has no schema class here, so by default {Router} refuses any query that
14
+ # reaches its fields. `subgraphs: { "reviews" => :fake }` swaps that
15
+ # refusal for schema-correct fabricated data — mid-migration that's often
16
+ # what you want: the fields exist, the shapes are right, and nothing has
17
+ # to be stood up to exercise the rest of the graph.
18
+ #
19
+ # It satisfies the same contract every other subgraph does, including the
20
+ # `_entities(representations:)` fetch a boundary crossing sends. Each
21
+ # representation names its own `__typename`, so the entity comes back as
22
+ # that type — a `_Entity` union faked the ordinary way would answer as a
23
+ # random member and match nothing the query asked for.
24
+ #
25
+ # Fabricated data is invented data, and a green test against invented
26
+ # data is worse than a red one — so {Router} marks every fetch that came
27
+ # from here (`faked: true` in #trace) and logs it at :warn.
28
+ class FakeSubgraph
29
+ # the subgraph being faked, as the supergraph names it
30
+ attr_reader :name
31
+
32
+ # the schema responses are fabricated against — the composed API
33
+ # schema, which carries every type this subgraph can be asked for
34
+ attr_reader :schema
35
+
36
+ def initialize(name, schema, **options)
37
+ @name = name
38
+ @schema = schema
39
+ @client = FakeClient.new(schema:, **options)
40
+ end
41
+
42
+ def inspect = "#<#{self.class.name} #{@name.inspect}>"
43
+ alias to_s inspect
44
+
45
+ # context: is accepted for contract parity and ignored — there are no
46
+ # resolvers here to receive one.
47
+ def execute(query, variables: {}, operation_name: nil, context: nil)
48
+ document = GraphQL.parse(query)
49
+ entities = entities_field(document)
50
+ return @client.execute(query, variables:, operation_name:) unless entities
51
+
52
+ { "data" => { "_entities" => entities_value(entities, document, variables) } }
53
+ end
54
+
55
+ private
56
+
57
+ # the router sends _entities as the operation's only root field
58
+ def entities_field(document)
59
+ operation = document.definitions.grep(GraphQL::Language::Nodes::OperationDefinition).first
60
+ operation&.selections&.find do |node|
61
+ node.is_a?(GraphQL::Language::Nodes::Field) && node.name == "_entities"
62
+ end
63
+ end
64
+
65
+ # one object per representation, in order and as the type it names —
66
+ # which is the contract _entities answers on
67
+ def entities_value(field, document, variables)
68
+ fragments = document.definitions
69
+ .grep(GraphQL::Language::Nodes::FragmentDefinition).to_h { |node| [node.name, node] }
70
+
71
+ representations(variables).map do |representation|
72
+ type_name = representation["__typename"] or raise GraphWeaver::Error,
73
+ "a representation sent to #{@name} carries no __typename: #{representation.inspect}"
74
+
75
+ @client.object(type_name, field.selections, fragments:)
76
+ end
77
+ end
78
+
79
+ def representations(variables)
80
+ found = variables.to_h.transform_keys(&:to_s)["representations"]
81
+ (found || []).map { |representation| representation.to_h.transform_keys(&:to_s) }
82
+ end
83
+ end
84
+ end
85
+ end