graph_weaver 0.6.1 → 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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1447 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +20 -6
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +497 -104
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +28 -0
  15. data/docs/scalars.md +190 -26
  16. data/docs/testing.md +457 -58
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +328 -3
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +404 -197
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +40 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +474 -106
  70. metadata +56 -1
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "fileutils"
5
5
  require "graphql"
6
+ require "json"
6
7
  require "yaml"
7
8
 
8
9
  module GraphWeaver
@@ -19,7 +20,7 @@ module GraphWeaver
19
20
  def initialize(path:, query:, variables:, recorded:, size:)
20
21
  super([
21
22
  "no recording for this request in #{GraphWeaver::Internal::Util.relative(path)}",
22
- " variables: #{GraphWeaver::Internal::Log.filter_variables(Internal::RequestKey.normalize_variables(variables)).inspect}",
23
+ " variables: #{JSON.generate(GraphWeaver::Internal::Log.filter_variables(Internal::RequestKey.normalize_variables(variables)))}",
23
24
  " #{self.class.recorded_summary(recorded, size)}",
24
25
  " query: #{Internal::RequestKey.summarize(query)}",
25
26
  "re-record it (GRAPHWEAVER_RECORD=1 with a client:), or delete the cassette to start over.",
@@ -30,7 +31,7 @@ module GraphWeaver
30
31
  return "no entry recorded for this query (#{size} in the cassette)" if recorded.empty?
31
32
 
32
33
  more = recorded.size > SHOWN ? " (+#{recorded.size - SHOWN} more)" : ""
33
- shown = recorded.first(SHOWN).map { |set| GraphWeaver::Internal::Log.filter_variables(set).inspect }
34
+ shown = recorded.first(SHOWN).map { |set| JSON.generate(GraphWeaver::Internal::Log.filter_variables(set)) }
34
35
  "#{recorded.size} #{(recorded.size == 1) ? "entry" : "entries"} recorded for this query, " \
35
36
  "with variables #{shown.join(", ")}#{more}"
36
37
  end
@@ -85,7 +86,7 @@ module GraphWeaver
85
86
 
86
87
  ["#{GraphWeaver::Internal::Util.relative(path)}: #{stale.size} stale (#{counted.join(", ")})"] +
87
88
  stale.flat_map do |entry|
88
- [" #{entry.module_name} #{entry.variables.inspect}", " #{entry.message}"]
89
+ [" #{entry.module_name} #{JSON.generate(entry.variables)}", " #{entry.message}"]
89
90
  end
90
91
  end
91
92
  end
@@ -160,7 +161,7 @@ module GraphWeaver
160
161
  # and nothing else notices when that server's answers drift out of the
161
162
  # shape the structs were generated for: `verify`, `queries:check` and
162
163
  # `schema:diff` all ask about the local side. Without this the drift
163
- # surfaces mid-spec as a `TypeError` naming a struct and a sorbet
164
+ # surfaces mid-spec as a `CastError` naming a struct and a sorbet
164
165
  # frame, with nothing pointing at the stale file.
165
166
  #
166
167
  # Matching is on the query text, which is the module that sent it — a
@@ -281,7 +282,7 @@ module GraphWeaver
281
282
 
282
283
  def initialize(schema:, seed: nil, values: nil)
283
284
  @schema = schema
284
- @values = Internal::Values.new(seed:, values:)
285
+ @values = Internal::Values.new(seed:, values:, schema:)
285
286
  end
286
287
 
287
288
  # The whole response, not just `data`: an error message routinely
@@ -0,0 +1,106 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+
6
+ module GraphWeaver
7
+ module Testing
8
+ # A Rack app serving any client — the {Router}, a live schema class, a
9
+ # {FakeClient} — at a GraphQL endpoint, so a query crosses a real wire:
10
+ # serialized by your transport, posted, deserialized by `from_h`.
11
+ #
12
+ # run GraphWeaver::Testing::Endpoint.new(router)
13
+ #
14
+ # In rspec that is the `graphql: :wire` tag, which mounts this behind
15
+ # your own transport's url (see graph_weaver/rspec). Anywhere else it is
16
+ # an ordinary Rack app — a rackup file, a Puma in a thread, WebMock's
17
+ # `to_rack`.
18
+ #
19
+ # It answers the way a router and graphql-ruby answer: a query the
20
+ # server can't parse or validate is a **200 carrying GraphQL errors**,
21
+ # not an HTTP failure. Only a request that isn't a GraphQL request at
22
+ # all — the wrong method, a body that isn't JSON — is a 400, and it says
23
+ # what it got.
24
+ #
25
+ # A client whose `context` is a **proc** is asked what this request's
26
+ # headers mean, per request: that is the identity-propagation seam, the
27
+ # one thing an in-process client can't test.
28
+ #
29
+ # Router.new(supergraph:, context: ->(headers) { { current_user: User.find_by(token: headers["Authorization"]) } })
30
+ class Endpoint
31
+ JSON_HEADERS = { "content-type" => "application/json" }.freeze
32
+ TEXT_HEADERS = { "content-type" => "text/plain" }.freeze
33
+ private_constant :JSON_HEADERS, :TEXT_HEADERS
34
+
35
+ # how much of an unservable body the 400 quotes back
36
+ EXCERPT = 200
37
+ private_constant :EXCERPT
38
+
39
+ def initialize(client)
40
+ @client = client
41
+ end
42
+
43
+ def call(env)
44
+ method = env["REQUEST_METHOD"]
45
+ return refuse("expected a POST of a GraphQL request, got #{method}") unless method == "POST"
46
+
47
+ body = env["rack.input"]&.read.to_s
48
+ request = begin
49
+ JSON.parse(body)
50
+ rescue JSON::ParserError => e
51
+ return refuse("expected a JSON GraphQL request body, got #{excerpt(body)} (#{e.message})")
52
+ end
53
+ unless request.is_a?(Hash) && request["query"].is_a?(String)
54
+ return refuse("expected a JSON GraphQL request body with a \"query\" string, got #{excerpt(body)}")
55
+ end
56
+
57
+ result = with_context(headers(env)) do
58
+ @client.execute(request["query"], variables: request["variables"] || {},
59
+ operation_name: request["operationName"])
60
+ end
61
+ [200, JSON_HEADERS, [JSON.generate(result)]]
62
+ end
63
+
64
+ # never leak the client's context (tokens, current_user)
65
+ def inspect = "#<#{self.class.name} client=#{@client.class}>"
66
+ alias to_s inspect
67
+
68
+ private
69
+
70
+ # A `context:` proc is answered from the request in hand, so it is
71
+ # resolved here and put back after — one request's identity must not
72
+ # leak into the next. A client with no context, or a hash one, is
73
+ # served untouched.
74
+ def with_context(headers)
75
+ context = @client.context if @client.respond_to?(:context) && @client.respond_to?(:context=)
76
+ return yield unless context.respond_to?(:call)
77
+
78
+ @client.context = context.call(headers)
79
+ begin
80
+ yield
81
+ ensure
82
+ @client.context = context
83
+ end
84
+ end
85
+
86
+ # Rack spells a header HTTP_X_CALLER; the proc reads "X-Caller".
87
+ # Capitalization is reconstructed, not remembered — the CGI env
88
+ # dropped it — so a header sent as X-CALLER arrives here as X-Caller.
89
+ def headers(env)
90
+ env.each_with_object({}) do |(key, value), headers|
91
+ name = case key
92
+ when /\AHTTP_(.+)\z/ then Regexp.last_match(1)
93
+ when "CONTENT_TYPE", "CONTENT_LENGTH" then key
94
+ end
95
+ next unless name && value.is_a?(String)
96
+
97
+ headers[name.downcase.split("_").map(&:capitalize).join("-")] = value
98
+ end
99
+ end
100
+
101
+ def excerpt(body) = body.empty? ? "an empty body" : body[0, EXCERPT].inspect
102
+
103
+ def refuse(message) = [400, TEXT_HEADERS, [message]]
104
+ end
105
+ end
106
+ end
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "json"
5
+ require "net/http" # Net::ReadTimeout, the shape a real read timeout arrives in
5
6
 
6
7
  module GraphWeaver
7
8
  module Testing
@@ -10,13 +11,14 @@ module GraphWeaver
10
11
  # server that misbehaves on cue:
11
12
  #
12
13
  # PersonQuery.execute(client: Failure.transport, id: "1") # TransportError
14
+ # PersonQuery.execute(client: Failure.timeout, id: "1") # TransportError, read timeout
13
15
  # PersonQuery.execute(client: Failure.server(status: 502), id: "1")
14
16
  # PersonQuery.execute(client: Failure.throttled, id: "1") # QueryError, code THROTTLED
15
17
  # PersonQuery.execute(client: Failure.stale_schema, id: "1") # schema_stale? => true
16
18
  #
17
19
  # For type mismatches, corrupt the wire with a FakeClient override:
18
20
  # FakeClient.new(schema:, overrides: { "Person.birthday" => 123 })
19
- # casting then raises GraphWeaver::TypeError, exactly as a bad server
21
+ # casting then raises GraphWeaver::CastError, exactly as a bad server
20
22
  # payload would. For partial failures, see FakeClient's fail_at:.
21
23
  module Failure
22
24
  include Kernel # for sorbet
@@ -25,12 +27,31 @@ module GraphWeaver
25
27
  # the request never reaches the server — cause preserved, and the
26
28
  # message shaped as the bundled transports shape it
27
29
  def transport(message = "simulated network failure", cause: SocketError)
30
+ network_failure(cause, message)
31
+ end
32
+
33
+ # The request went out and no answer came back in time — net/http's own
34
+ # Net::ReadTimeout as #cause, so a spec says "it timed out" without
35
+ # naming net/http's classes. Retriable, but a read timeout says nothing
36
+ # about whether the server applied the request, which is why Retry gives
37
+ # a mutation one attempt.
38
+ def timeout(message = "simulated read timeout")
39
+ network_failure(Net::ReadTimeout, message)
40
+ end
41
+
42
+ # What Transport does with a network-level failure: a TransportError
43
+ # reading "Class: detail", the original preserved as #cause. The detail
44
+ # is passed rather than read off the exception — Net::ReadTimeout's own
45
+ # initialize takes the socket it gave up on, not a message, so a string
46
+ # handed to `raise` lands in quotes where the socket goes.
47
+ def network_failure(cause, message)
28
48
  FailureClient.new do
29
- raise cause, message
49
+ raise cause
30
50
  rescue cause => e
31
- raise GraphWeaver::TransportError, "#{e.class}: #{e.message}"
51
+ raise GraphWeaver::TransportError, "#{e.class}: #{message}"
32
52
  end
33
53
  end
54
+ private_class_method :network_failure
34
55
 
35
56
  # The server answered non-2xx. headers: is where the answer to "wait,
36
57
  # then" lives — ServerError#retry_after and #throttled? read it, so a
@@ -41,24 +62,60 @@ module GraphWeaver
41
62
  FailureClient.new { raise GraphWeaver::ServerError.new(status:, body:, headers:) }
42
63
  end
43
64
 
44
- # top-level GraphQL errors: strings, or hashes with message/path/
45
- # extensions; data: rides along for partial-failure envelopes
46
- def graphql(*errors, data: nil, extensions: {})
47
- normalized = errors.flatten.map do |error|
48
- error.is_a?(String) ? { "message" => error } : JSON.parse(JSON.generate(error))
65
+ # The wire fields an error carries, beyond its message. `code:` is the
66
+ # sugar fail_at: already uses extensions.code, the one every server
67
+ # states. Anything else is refused by name: a swallowed keyword leaves a
68
+ # simulated failure that doesn't simulate what the example asked for.
69
+ ERROR_FIELDS = %i[code extensions path locations].freeze
70
+ private_constant :ERROR_FIELDS
71
+
72
+ # Top-level GraphQL errors — a **whole-response** failure unless data:
73
+ # rides along. Each positional is a String (just the message) or a Hash
74
+ # in the wire error shape; the fields of ONE error may be named beside
75
+ # its message instead:
76
+ #
77
+ # Failure.graphql("boom")
78
+ # Failure.graphql("boom", code: "BAD_USER_INPUT", path: ["adopt"])
79
+ # Failure.graphql("min must be at least 1", code: "BAD_USER_INPUT",
80
+ # extensions: { "input" => { "kind" => "out_of_range", "min" => 1 } })
81
+ # Failure.graphql({ message: "a", path: ["x"] }, { message: "b" }, data: { "x" => nil })
82
+ def graphql(*errors, data: nil, **fields)
83
+ unknown = fields.keys - ERROR_FIELDS
84
+ unless unknown.empty?
85
+ raise ArgumentError, "Failure.graphql: unknown keyword(s) #{unknown.join(", ")} — " \
86
+ "expected data:, or #{ERROR_FIELDS.join(", ")} to shape the error"
49
87
  end
50
88
 
51
- response = { "errors" => normalized }
89
+ errors = errors.flatten
90
+ unless fields.empty? || errors.one?
91
+ raise ArgumentError, "Failure.graphql: #{fields.keys.join(", ")} shapes one error, " \
92
+ "got #{errors.size} — give each its own hash"
93
+ end
94
+
95
+ response = { "errors" => errors.map { |error| wire_error(error, fields) } }
52
96
  response["data"] = data if data
53
- response["extensions"] = JSON.parse(JSON.generate(extensions)) unless extensions.empty?
54
97
  FailureClient.new { response }
55
98
  end
56
99
 
100
+ # a String is its message; a Hash is the wire error as written. The
101
+ # kwargs merge on top, so `code:` and `extensions:` compose.
102
+ def wire_error(error, fields)
103
+ wire = error.is_a?(String) ? { "message" => error } : JSON.parse(JSON.generate(error))
104
+ return wire if fields.empty?
105
+
106
+ extensions = JSON.parse(JSON.generate(fields[:extensions] || {}))
107
+ extensions["code"] = fields[:code].to_s if fields[:code]
108
+ wire.merge!(JSON.parse(JSON.generate(fields.slice(:path, :locations))))
109
+ wire["extensions"] = (wire["extensions"] || {}).merge(extensions) unless extensions.empty?
110
+ wire
111
+ end
112
+ private_class_method :wire_error
113
+
57
114
  def throttled
58
115
  # a code from the list #throttled? recognizes, not one spelled here —
59
116
  # a fake that doesn't trip the predicate it exists to exercise is worse
60
- # than no fake. (array-wrapped so the hash can't parse as kwargs)
61
- graphql([{ message: "rate limited", extensions: { code: GraphWeaver::GraphQLError::THROTTLE_CODES.first } }])
117
+ # than no fake
118
+ graphql("rate limited", code: GraphWeaver::GraphQLError::THROTTLE_CODES.first)
62
119
  end
63
120
 
64
121
  # A validation-shaped rejection — trips schema_stale? and its
@@ -30,13 +30,15 @@ require_relative "../parsing"
30
30
  # takes one. Keys are checked against the schema, since a typo'd one would
31
31
  # pin nothing and leave the test green. (A pin with a wrong-typed value is
32
32
  # also the way to simulate a corrupt payload — casting raises
33
- # GraphWeaver::TypeError.)
33
+ # GraphWeaver::CastError.)
34
34
  #
35
35
  # FakeClient.new({ "Money" => "12.00", "Person" => build(:person),
36
36
  # "email" => -> { "test@example.com" } }, schema:)
37
37
  #
38
- # Options are lowercase words, so a key with a dot or a leading capital is
39
- # a pin wherever it is written `overrides:` is the same hash by keyword,
38
+ # Pins and options are the same keywords, told apart by a lookup: a key
39
+ # this fake takes is an option, a key your schema knows is a pin, and a key
40
+ # that is neither is refused naming both. So a lowercase type pins as
41
+ # readily as a capitalized one. `overrides:` is the same hash by keyword,
40
42
  # and the leading one wins where both name a key.
41
43
  #
42
44
  # A pin covers a whole subtree as readily as a leaf, and **merges** rather
@@ -53,6 +55,11 @@ require_relative "../parsing"
53
55
  # other way round: the reader is the snake_cased field name, not the alias,
54
56
  # and a field it doesn't answer is fabricated.
55
57
  #
58
+ # registry: whose register_scalar/register_enum calls the fabricated values
59
+ # have to satisfy — GraphWeaver::Graph#registry, since a Money registered
60
+ # for one graph is not a Money for the next. Left unsaid it is read back off
61
+ # schema:, which is the answer for every app with one graph.
62
+ #
56
63
  # requests: every execute, in order ({ query:, variables:, operation_name: })
57
64
  # — "did we send the right variables", and "did we call it at all".
58
65
  #
@@ -65,11 +72,16 @@ require_relative "../parsing"
65
72
  # FakeClient.new(schema:, fail_at: "person.pets.name")
66
73
  # FakeClient.new(schema:, fail_at: { path: "person.email", message: "hidden", code: "PRIVATE" })
67
74
  #
75
+ # The path is response keys joined by dots, and a list index is a segment
76
+ # of its own — "people.0.pets.1.name". State only the indices you mean;
77
+ # the rest match any position, so "people.pets.name" fails the first
78
+ # element the walk reaches.
79
+ #
68
80
  # errors: appends verbatim top-level errors alongside the fake data.
69
81
  #
70
82
  # Type mismatches: corrupt: names fields ("Type.field") that should
71
83
  # arrive wire-corrupted — a wrong-typed value derived from the schema,
72
- # so casting raises GraphWeaver::TypeError. One spec checks the failure
84
+ # so casting raises GraphWeaver::CastError. One spec checks the failure
73
85
  # path; every other spec gets working data:
74
86
  #
75
87
  # FakeClient.new(schema:, corrupt: "Person.birthday")
@@ -81,6 +93,14 @@ require_relative "../parsing"
81
93
  #
82
94
  # FakeClient.new(schema:, null_chance: 1.0) # everything nullable, null
83
95
  #
96
+ # list_size: how long an unbounded list is — an Integer exactly, a Range
97
+ # randomized within it, and a Hash per list, keyed the way a pin is (a
98
+ # "Type.field" coordinate or a bare field name) with "default" for the rest.
99
+ # Every list the walk reaches reads this, so nested lists MULTIPLY under one
100
+ # number: n rows each fabricate n tags. Naming the inner one flattens that.
101
+ #
102
+ # FakeClient.new(schema:, list_size: { "Row.tags" => 3, default: 500 })
103
+ #
84
104
  # seed: makes a run reproducible (also seeds faker). schema:, overrides:
85
105
  # and list_size: fall back to GraphWeaver::Testing.config — and the
86
106
  # config's schema falls back to the committed dump.
@@ -113,44 +133,45 @@ class GraphWeaver::Testing::FakeClient
113
133
  # misspelled key arrived as a bare "unknown keyword" from inside the
114
134
  # fabricator, naming neither the accepted options nor the one you meant.
115
135
  OPTIONS = {
116
- schema: nil, overrides: {}, seed: nil, values: nil, list_size: nil,
136
+ schema: nil, registry: nil, overrides: {}, seed: nil, values: nil, list_size: nil,
117
137
  null_chance: nil, errors: nil, fail_at: nil, corrupt: nil,
118
138
  }.freeze
119
139
 
120
- # One rule tells a pin from an option: options are lowercase words, and
121
- # anything with a dot or a leading capital names something in the schema.
122
- # Ruby 3 hands every braceless pair to **options — String keys included —
123
- # so `FakeClient.new("Order.total" => "9", seed: 1)` arrives whole and is
124
- # split here, as is a quoted symbol (`"Order.total":`) or a hash forwarded
125
- # by a router's fake:.
126
- PIN_KEY = /\A[A-Z]|\./
127
-
128
140
  # JSON's own types are already on the wire: at a leaf they skip the
129
- # registry's serializer, and at a composite position (a Hash aside, which
130
- # is response keys) they pin the field as written — nil is null, the rest
131
- # is the corrupt payload the example asked for.
132
- WIRE = [NilClass, TrueClass, FalseClass, Numeric, String, Symbol, Array, Hash].freeze
141
+ # registry's serializer (Values#wire), and at a composite position (a Hash
142
+ # aside, which is response keys) they pin the field as written — nil is
143
+ # null, the rest is the corrupt payload the example asked for.
144
+ WIRE = GraphWeaver::Internal::Values::WIRE
133
145
 
134
146
  # Methods every Ruby object answers aren't fields: a schema does have a
135
147
  # `hash` or a `count`, and a Struct answers both with plausible nonsense
136
148
  # where fabricating is right.
137
149
  RUBY_OWN = [BasicObject, Kernel, Object, Comparable, Enumerable, Struct, Data].freeze
138
- private_constant :OPTIONS, :PIN_KEY, :WIRE, :RUBY_OWN
150
+ private_constant :OPTIONS, :WIRE, :RUBY_OWN
139
151
 
140
152
  def initialize(pins = {}, **options)
141
- pins, options = check_options!(pins, options)
142
153
  config = GraphWeaver::Testing.config
154
+ # resolved before the split, because the split asks the schema which keys
155
+ # are pins
143
156
  @schema = options[:schema] || config.schema || raise(GraphWeaver::Error,
144
157
  "no schema to fake against — set GraphWeaver::Testing.config.schema, pass schema:, " \
145
158
  "or commit a schema dump at #{GraphWeaver.schema_path}")
159
+ pins, options = check_options!(pins, options)
146
160
  # last wins, narrowest last: the suite's, then overrides:, then the pins
147
161
  # this fake was handed outright
148
162
  @overrides = [config.overrides, options[:overrides], pins]
149
163
  .map { |hash| hash.transform_keys(&:to_s) }.reduce(:merge)
150
164
  GraphWeaver::Internal::Overrides.validate!(@schema, @overrides)
165
+ # A graph whose schema is a file can't be matched back off the schema
166
+ # object — SchemaLoader builds a fresh anonymous class each load — so a
167
+ # caller holding the graph passes its registry rather than letting the
168
+ # lookup fall through to the default one.
169
+ @registry = options[:registry] || GraphWeaver::Internal::Util.registry_for(@schema)
151
170
  @values = GraphWeaver::Internal::Values.new(seed: options[:seed], values: options[:values],
152
- pins: @overrides)
171
+ pins: @overrides, schema: @schema, registry: @registry)
153
172
  @list_size = options[:list_size] || config.list_size
173
+ @list_size = @list_size.transform_keys(&:to_s) if @list_size.is_a?(Hash)
174
+ GraphWeaver::Internal::Overrides.validate_list_size!(@schema, @list_size)
154
175
  @null_chance = options[:null_chance] || 0.0
155
176
  # NOT Array(): it would explode a bare Hash into key/value pairs
156
177
  @extra_errors = wrap(options[:errors]).map { |error| normalize_error(error) }
@@ -229,21 +250,39 @@ class GraphWeaver::Testing::FakeClient
229
250
 
230
251
  private
231
252
 
232
- # A misspelled option pins nothing and leaves the example green the same
233
- # silent pass a typo'd override key is refused for.
253
+ # One rule tells a pin from an option, and it is a lookup rather than a
254
+ # guess at spelling: a key this fake takes is an option, a key the schema
255
+ # knows is a pin, and a key that is neither is a typo — refused naming both
256
+ # dictionaries, since only the author knows which they were reaching for. A
257
+ # misspelled option would otherwise pin nothing and leave the example green.
258
+ #
259
+ # Ruby 3 hands every braceless pair to **options — String keys included —
260
+ # so `FakeClient.new("Order.total" => "9", seed: 1)` arrives whole and is
261
+ # split here, as is a quoted symbol (`"Order.total":`) or a hash forwarded
262
+ # by a router's fake:. A leading positional hash is only ever pins, which
263
+ # is the spelling for a schema whose own vocabulary collides with an
264
+ # option name.
234
265
  def check_options!(pins, options)
235
- options, keyed_pins = options.partition { |key, _| !PIN_KEY.match?(key.to_s) }.map(&:to_h)
236
- # what was written as a leading hash wins: it is the one form that can
237
- # only ever be a pin
238
- pins = keyed_pins.merge(pins.to_h)
266
+ options, keyed_pins = options.partition { |key, _| OPTIONS.key?(key) }.map(&:to_h)
267
+ unknown = keyed_pins.keys.reject { |key| GraphWeaver::Internal::Overrides.schema_reference?(@schema, key) }
268
+ refuse_key!(unknown.first) if unknown.any?
239
269
 
240
- unknown = options.keys - OPTIONS.keys
241
- return [pins, OPTIONS.merge(options)] if unknown.empty?
270
+ [keyed_pins.merge(pins.to_h), OPTIONS.merge(options)]
271
+ end
242
272
 
243
- suggestion = GraphWeaver::Internal::Util.did_you_mean(OPTIONS.keys.map(&:to_s), unknown.first.to_s)
244
- hint = suggestion ? " — did you mean #{suggestion}:?" : "."
245
- raise ArgumentError, "a fake doesn't take #{unknown.first}:#{hint} It takes " \
246
- "#{OPTIONS.keys.map { |name| "#{name}:" }.join(", ")}"
273
+ def refuse_key!(key)
274
+ dictionary = OPTIONS.keys.map(&:to_s) + GraphWeaver::Internal::Overrides.pin_names(@schema)
275
+ suggestion = GraphWeaver::Internal::Util.did_you_mean(dictionary, key.to_s)
276
+ hint = if suggestion.nil?
277
+ "."
278
+ elsif OPTIONS.key?(suggestion.to_sym)
279
+ " — did you mean #{suggestion}:?"
280
+ else
281
+ " — did you mean the pin #{suggestion.inspect}?"
282
+ end
283
+ raise ArgumentError, "a fake doesn't take #{key}:#{hint} It takes " \
284
+ "#{OPTIONS.keys.map { |name| "#{name}:" }.join(", ")}, and pins keyed by anything in your " \
285
+ "schema — a type, a \"Type.field\" coordinate, or a field name"
247
286
  end
248
287
 
249
288
  def rng = @values.rng
@@ -298,7 +337,37 @@ class GraphWeaver::Testing::FakeClient
298
337
  end
299
338
 
300
339
  def normalize_fail_spec(spec)
301
- spec.is_a?(String) ? { "path" => spec } : JSON.parse(JSON.generate(spec))
340
+ normalized = spec.is_a?(String) ? { "path" => spec } : JSON.parse(JSON.generate(spec))
341
+ normalized["chain"] = fail_chain(normalized["path"])
342
+ normalized
343
+ end
344
+
345
+ # A fail_at path as (field, indices) pairs: "people.0.pets.name" is people
346
+ # at index 0, then pets at any index, then name. An index you state has to
347
+ # match; one you leave out matches every position, so the plain
348
+ # "people.pets.name" fails the first element the walk reaches — which is
349
+ # what it has always done. Silently ignoring an index was the alternative,
350
+ # and a fail_at that never fires looks exactly like a passing test.
351
+ def fail_chain(path)
352
+ unless path.is_a?(String) && !path.empty?
353
+ raise ArgumentError, "fail_at: expected a response path like \"person.email\", got #{path.inspect}"
354
+ end
355
+
356
+ segments = path.split(".").map { |segment| segment.match?(/\A\d+\z/) ? Integer(segment) : segment }
357
+ if segments.first.is_a?(Integer)
358
+ raise ArgumentError, "fail_at: #{path.inspect} starts with a list index — a path starts with a field"
359
+ end
360
+
361
+ path_chain(segments)
362
+ end
363
+
364
+ # the shared fold: a fail_at path and the walk's own @path become the same
365
+ # shape, so one comparison serves both
366
+ def path_chain(segments)
367
+ segments.each_with_object([]) do |segment, chain|
368
+ field = chain.last
369
+ field && segment.is_a?(Integer) ? field.last << segment : chain << [segment, []]
370
+ end
302
371
  end
303
372
 
304
373
  # pins: the response keys an override pinned at this object, merged in as
@@ -434,10 +503,7 @@ class GraphWeaver::Testing::FakeClient
434
503
  when "NON_NULL" then wire_value(type.of_type, value, coordinate)
435
504
  when "LIST"
436
505
  value.is_a?(Array) ? value.map { |element| wire_value(type.of_type, element, coordinate) } : value
437
- when "SCALAR"
438
- return value if wire?(value)
439
-
440
- GraphWeaver::Codegen.scalar(type.graphql_name, coordinate).serialize_value(value)
506
+ when "SCALAR" then @values.wire(type.graphql_name, value, coordinate)
441
507
  when "ENUM" then value.is_a?(T::Enum) ? value.serialize : value
442
508
  else value # a composite: pinned_object reads it, one level down
443
509
  end
@@ -506,24 +572,47 @@ class GraphWeaver::Testing::FakeClient
506
572
  end
507
573
  end
508
574
 
509
- # first untriggered fail_at spec whose field chain (indices stripped)
510
- # matches where we are
575
+ # first untriggered fail_at spec whose chain matches where we are
511
576
  def matching_failure
512
- chain = @path.reject { |segment| segment.is_a?(Integer) }.join(".")
513
- @fail_at.find { |spec| !spec["triggered"] && spec["path"] == chain }
577
+ here = path_chain(@path)
578
+ @fail_at.find { |spec| !spec["triggered"] && at?(spec["chain"], here) }
579
+ end
580
+
581
+ def at?(chain, here)
582
+ return false unless chain.size == here.size
583
+
584
+ chain.zip(here).all? do |(field, indices), (at, positions)|
585
+ field == at && indices.each_with_index.all? { |index, depth| positions[depth] == index }
586
+ end
514
587
  end
515
588
 
516
589
  # honor pagination-ish arg semantics: first/last/limit caps the fabricated
517
590
  # list length, whether it arrives as a literal or as a variable
518
- def list_length(node)
591
+ def list_length(node, coordinate)
519
592
  argument = node.arguments.find { |arg| %w[first last limit].include?(arg.name) }
520
593
  capped = argument && argument_value(argument)
521
594
  # Array.new(-1) is "negative array size" out of the fabricator's guts; a
522
595
  # cap below zero asks for nothing, which is what a page of none is
523
596
  return [capped, 0].max if capped.is_a?(Integer)
524
597
 
598
+ size = list_size_for(coordinate, node.name)
525
599
  # an Integer list_size means exactly that many; a Range randomizes within it
526
- @list_size.is_a?(Range) ? rng.rand(@list_size) : @list_size
600
+ size.is_a?(Range) ? rng.rand(size) : size
601
+ end
602
+
603
+ # How long an unbounded list is. A Hash says it per list, read most
604
+ # specific first like a pin — which is what keeps nested lists from
605
+ # multiplying: every list the walk reaches re-reads this, so one number
606
+ # for all of them is n rows x n tags.
607
+ def list_size_for(coordinate, name)
608
+ return @list_size unless @list_size.is_a?(Hash)
609
+
610
+ @list_size.fetch(coordinate) do
611
+ @list_size.fetch(name) do
612
+ @list_size.fetch(GraphWeaver::Internal::Overrides::LIST_SIZE_DEFAULT,
613
+ GraphWeaver::Testing::Config::DEFAULT_LIST_SIZE)
614
+ end
615
+ end
527
616
  end
528
617
 
529
618
  def type_value(type, node, selections, coordinate: nil, non_null: false)
@@ -536,7 +625,7 @@ class GraphWeaver::Testing::FakeClient
536
625
 
537
626
  case type.kind.name
538
627
  when "LIST"
539
- elements = Array.new(list_length(node)) do |index|
628
+ elements = Array.new(list_length(node, coordinate)) do |index|
540
629
  @path.push(index)
541
630
  element = type_value(type.of_type, node, selections, coordinate:)
542
631
  @path.pop