graph_weaver 0.4.6 → 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 +1314 -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 -154
  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 -258
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -124
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +593 -334
  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 +9 -1
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +14 -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 +7 -0
  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 +794 -59
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +43 -8
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +160 -61
  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 +181 -21
  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 +28 -10
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +67 -14
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +389 -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,9 +105,25 @@ 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
 
@@ -84,6 +140,27 @@ class GraphWeaver::Testing::FakeClient
84
140
  response
85
141
  end
86
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
+
87
164
  private
88
165
 
89
166
  def rng = @values.rng
@@ -104,14 +181,24 @@ class GraphWeaver::Testing::FakeClient
104
181
  spec.is_a?(String) ? { "path" => spec } : JSON.parse(JSON.generate(spec))
105
182
  end
106
183
 
107
- def object_value(type, selections)
108
- result = {}
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)
109
187
  # gather (not each_field) so a field selected twice — `a { x } a { y }` —
110
188
  # fabricates the MERGED shape codegen's struct expects, not last-writer-wins
111
- gather(type, selections).each do |key, nodes|
189
+ fields = gather(type, selections)
190
+ check_pins!(fields, pins, source) if pins
191
+
192
+ result = {}
193
+ fields.each do |key, nodes|
112
194
  node = nodes.first
195
+ pin = (pins && pins.key?(key)) ? pins[key] : UNPINNED
113
196
  @path.push(key)
114
- value = node.name == "__typename" ? type.graphql_name : field_value(type, node, nodes.flat_map(&:selections))
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
115
202
  @path.pop
116
203
 
117
204
  if value.equal?(NULL_BUBBLE)
@@ -132,7 +219,7 @@ class GraphWeaver::Testing::FakeClient
132
219
  @schema.get_field(type.graphql_name, node.name).type.kind.name == "NON_NULL"
133
220
  end
134
221
 
135
- def field_value(parent_type, node, selections)
222
+ def field_value(parent_type, node, selections, pin = UNPINNED, source = nil)
136
223
  if (spec = matching_failure)
137
224
  @failures << {
138
225
  "message" => spec["message"] || "simulated failure",
@@ -143,19 +230,92 @@ class GraphWeaver::Testing::FakeClient
143
230
  return NULL_BUBBLE
144
231
  end
145
232
 
146
- override = @overrides.fetch("#{parent_type.graphql_name}.#{node.name}") do
147
- @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)
148
238
  end
149
- return override.is_a?(Proc) ? override.call : override unless override.nil?
150
239
 
151
240
  field_type = @schema.get_field(parent_type.graphql_name, node.name).type
152
- if @corrupt.include?("#{parent_type.graphql_name}.#{node.name}")
153
- return corrupt_value(field_type)
154
- end
241
+ return pinned_value(field_type, node, selections, pin, source) unless pin.equal?(UNPINNED)
242
+ return corrupt_value(field_type) if @corrupt.include?(coordinate)
155
243
 
156
244
  type_value(field_type, node, selections)
157
245
  end
158
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\"."
291
+ end
292
+
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(", ")})."
314
+ end
315
+
316
+ # where the walk is, for a message: "reader.orders.0"
317
+ def location = @path.empty? ? "the root" : @path.join(".")
318
+
159
319
  # a value casting can't accept, derived from the field's own type — and
160
320
  # wrapped per list layer so the corruption lands on the element cast
161
321
  def corrupt_value(type)
@@ -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