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
@@ -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
@@ -114,11 +115,8 @@ module GraphWeaver
114
115
 
115
116
  def initialize(path)
116
117
  @path = Testing.cassette_path(path)
117
- @entries = File.exist?(@path) ? YAML.safe_load_file(@path, aliases: true) : []
118
+ @entries = read_entries
118
119
  @flagged = []
119
- # record is read-modify-write; two threads recording through one
120
- # cassette (a parallel spec run) would each save a snapshot missing
121
- # the other's entry — atomic_write keeps the file whole, not complete
122
120
  @lock = Mutex.new
123
121
  end
124
122
 
@@ -146,7 +144,13 @@ module GraphWeaver
146
144
  entry["response"] = response
147
145
 
148
146
  wanted = Internal::RequestKey.for(query, variables, operation_name)
149
- @lock.synchronize do
147
+ # Recording rewrites the whole file, and parallel_tests points several
148
+ # processes at one cassette — so the read-modify-write happens under a
149
+ # lock every recorder shares, re-reading inside it. The snapshot taken
150
+ # at construction is already missing whatever another process recorded
151
+ # since, and saving it would throw those entries away.
152
+ locked do
153
+ @entries = read_entries
150
154
  @entries.reject! { |existing| Internal::RequestKey.for_entry(existing) == wanted }
151
155
  @entries << entry
152
156
  save
@@ -160,7 +164,7 @@ module GraphWeaver
160
164
  # and nothing else notices when that server's answers drift out of the
161
165
  # shape the structs were generated for: `verify`, `queries:check` and
162
166
  # `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
167
+ # surfaces mid-spec as a `CastError` naming a struct and a sorbet
164
168
  # frame, with nothing pointing at the stale file.
165
169
  #
166
170
  # Matching is on the query text, which is the module that sent it — a
@@ -197,6 +201,26 @@ module GraphWeaver
197
201
 
198
202
  private
199
203
 
204
+ def read_entries = File.exist?(@path) ? YAML.safe_load_file(@path, aliases: true) : []
205
+
206
+ # Serialize a read-modify-write against every other recorder, in this
207
+ # process and any other. The Mutex is the threads; the flock is the
208
+ # processes. Both, because flock is held per open file, so one process's
209
+ # two threads would each take their own.
210
+ def locked
211
+ @lock.synchronize do
212
+ FileUtils.mkdir_p(File.dirname(@path))
213
+ File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
214
+ lock.flock(File::LOCK_EX)
215
+ yield
216
+ end
217
+ end
218
+ end
219
+
220
+ # A sidecar, not the cassette itself: save renames a fresh file into
221
+ # place, so a lock held on the replaced inode guards nothing.
222
+ def lock_path = "#{@path}.lock"
223
+
200
224
  def save
201
225
  yaml = YAML.dump(@entries)
202
226
  FileUtils.mkdir_p(File.dirname(@path))
@@ -281,7 +305,7 @@ module GraphWeaver
281
305
 
282
306
  def initialize(schema:, seed: nil, values: nil)
283
307
  @schema = schema
284
- @values = Internal::Values.new(seed:, values:)
308
+ @values = Internal::Values.new(seed:, values:, schema:)
285
309
  end
286
310
 
287
311
  # The whole response, not just `data`: an error message routinely
@@ -0,0 +1,107 @@
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
+ #
31
+ # Answering that proc means writing the client's context for the length
32
+ # of one dispatch, so the client is what resolves it: any client
33
+ # answering `with_request_context(headers) { }` gets the seam, which
34
+ # {InProcess} and {Router} take from {GraphWeaver::ContextSeam}. A client
35
+ # without it is served untouched, and concurrently.
36
+ class Endpoint
37
+ JSON_HEADERS = { "content-type" => "application/json" }.freeze
38
+ TEXT_HEADERS = { "content-type" => "text/plain" }.freeze
39
+ private_constant :JSON_HEADERS, :TEXT_HEADERS
40
+
41
+ # how much of an unservable body the 400 quotes back
42
+ EXCERPT = 200
43
+ private_constant :EXCERPT
44
+
45
+ def initialize(client)
46
+ @client = client
47
+ end
48
+
49
+ def call(env)
50
+ method = env["REQUEST_METHOD"]
51
+ return refuse("expected a POST of a GraphQL request, got #{method}") unless method == "POST"
52
+
53
+ body = env["rack.input"]&.read.to_s
54
+ request = begin
55
+ JSON.parse(body)
56
+ rescue JSON::ParserError => e
57
+ return refuse("expected a JSON GraphQL request body, got #{excerpt(body)} (#{e.message})")
58
+ end
59
+ unless request.is_a?(Hash) && request["query"].is_a?(String)
60
+ return refuse("expected a JSON GraphQL request body with a \"query\" string, got #{excerpt(body)}")
61
+ end
62
+
63
+ result = with_context(headers(env)) do
64
+ @client.execute(request["query"], variables: request["variables"] || {},
65
+ operation_name: request["operationName"])
66
+ end
67
+ [200, JSON_HEADERS, [JSON.generate(result)]]
68
+ end
69
+
70
+ # never leak the client's context (tokens, current_user)
71
+ def inspect = "#<#{self.class.name} client=#{@client.class}>"
72
+ alias to_s inspect
73
+
74
+ private
75
+
76
+ # A `context:` proc is answered from the request in hand, and the
77
+ # client's context is what gets written to answer it — so the client
78
+ # does it, under its own lock. This endpoint is built per request by
79
+ # `graphql: :wire`; a lock held here would guard nothing the next
80
+ # request shares.
81
+ def with_context(headers, &block)
82
+ return yield unless @client.respond_to?(:with_request_context)
83
+
84
+ @client.with_request_context(headers, &block)
85
+ end
86
+
87
+ # Rack spells a header HTTP_X_CALLER; the proc reads "X-Caller".
88
+ # Capitalization is reconstructed, not remembered — the CGI env
89
+ # dropped it — so a header sent as X-CALLER arrives here as X-Caller.
90
+ def headers(env)
91
+ env.each_with_object({}) do |(key, value), headers|
92
+ name = case key
93
+ when /\AHTTP_(.+)\z/ then Regexp.last_match(1)
94
+ when "CONTENT_TYPE", "CONTENT_LENGTH" then key
95
+ end
96
+ next unless name && value.is_a?(String)
97
+
98
+ headers[name.downcase.split("_").map(&:capitalize).join("-")] = value
99
+ end
100
+ end
101
+
102
+ def excerpt(body) = body.empty? ? "an empty body" : body[0, EXCERPT].inspect
103
+
104
+ def refuse(message) = [400, TEXT_HEADERS, [message]]
105
+ end
106
+ end
107
+ 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,49 @@ 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
+
151
+ # The scalars the GraphQL spec serializes as JSON strings, whatever Ruby
152
+ # holds them.
153
+ STRING_SCALARS = %w[ID String].freeze
154
+ private_constant :OPTIONS, :WIRE, :RUBY_OWN, :STRING_SCALARS
139
155
 
140
156
  def initialize(pins = {}, **options)
141
- pins, options = check_options!(pins, options)
142
157
  config = GraphWeaver::Testing.config
158
+ # resolved before the split, because the split asks the schema which keys
159
+ # are pins
143
160
  @schema = options[:schema] || config.schema || raise(GraphWeaver::Error,
144
161
  "no schema to fake against — set GraphWeaver::Testing.config.schema, pass schema:, " \
145
162
  "or commit a schema dump at #{GraphWeaver.schema_path}")
163
+ pins, options = check_options!(pins, options)
146
164
  # last wins, narrowest last: the suite's, then overrides:, then the pins
147
165
  # this fake was handed outright
148
166
  @overrides = [config.overrides, options[:overrides], pins]
149
167
  .map { |hash| hash.transform_keys(&:to_s) }.reduce(:merge)
150
168
  GraphWeaver::Internal::Overrides.validate!(@schema, @overrides)
169
+ # A graph whose schema is a file can't be matched back off the schema
170
+ # object — SchemaLoader builds a fresh anonymous class each load — so a
171
+ # caller holding the graph passes its registry rather than letting the
172
+ # lookup fall through to the default one.
173
+ @registry = options[:registry] || GraphWeaver::Internal::Util.registry_for(@schema)
151
174
  @values = GraphWeaver::Internal::Values.new(seed: options[:seed], values: options[:values],
152
- pins: @overrides)
175
+ pins: @overrides, schema: @schema, registry: @registry)
153
176
  @list_size = options[:list_size] || config.list_size
177
+ @list_size = @list_size.transform_keys(&:to_s) if @list_size.is_a?(Hash)
178
+ GraphWeaver::Internal::Overrides.validate_list_size!(@schema, @list_size)
154
179
  @null_chance = options[:null_chance] || 0.0
155
180
  # NOT Array(): it would explode a bare Hash into key/value pairs
156
181
  @extra_errors = wrap(options[:errors]).map { |error| normalize_error(error) }
@@ -229,21 +254,39 @@ class GraphWeaver::Testing::FakeClient
229
254
 
230
255
  private
231
256
 
232
- # A misspelled option pins nothing and leaves the example green the same
233
- # silent pass a typo'd override key is refused for.
257
+ # One rule tells a pin from an option, and it is a lookup rather than a
258
+ # guess at spelling: a key this fake takes is an option, a key the schema
259
+ # knows is a pin, and a key that is neither is a typo — refused naming both
260
+ # dictionaries, since only the author knows which they were reaching for. A
261
+ # misspelled option would otherwise pin nothing and leave the example green.
262
+ #
263
+ # Ruby 3 hands every braceless pair to **options — String keys included —
264
+ # so `FakeClient.new("Order.total" => "9", seed: 1)` arrives whole and is
265
+ # split here, as is a quoted symbol (`"Order.total":`) or a hash forwarded
266
+ # by a router's fake:. A leading positional hash is only ever pins, which
267
+ # is the spelling for a schema whose own vocabulary collides with an
268
+ # option name.
234
269
  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)
270
+ options, keyed_pins = options.partition { |key, _| OPTIONS.key?(key) }.map(&:to_h)
271
+ unknown = keyed_pins.keys.reject { |key| GraphWeaver::Internal::Overrides.schema_reference?(@schema, key) }
272
+ refuse_key!(unknown.first) if unknown.any?
239
273
 
240
- unknown = options.keys - OPTIONS.keys
241
- return [pins, OPTIONS.merge(options)] if unknown.empty?
274
+ [keyed_pins.merge(pins.to_h), OPTIONS.merge(options)]
275
+ end
242
276
 
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(", ")}"
277
+ def refuse_key!(key)
278
+ dictionary = OPTIONS.keys.map(&:to_s) + GraphWeaver::Internal::Overrides.pin_names(@schema)
279
+ suggestion = GraphWeaver::Internal::Util.did_you_mean(dictionary, key.to_s)
280
+ hint = if suggestion.nil?
281
+ "."
282
+ elsif OPTIONS.key?(suggestion.to_sym)
283
+ " — did you mean #{suggestion}:?"
284
+ else
285
+ " — did you mean the pin #{suggestion.inspect}?"
286
+ end
287
+ raise ArgumentError, "a fake doesn't take #{key}:#{hint} It takes " \
288
+ "#{OPTIONS.keys.map { |name| "#{name}:" }.join(", ")}, and pins keyed by anything in your " \
289
+ "schema — a type, a \"Type.field\" coordinate, or a field name"
247
290
  end
248
291
 
249
292
  def rng = @values.rng
@@ -298,7 +341,37 @@ class GraphWeaver::Testing::FakeClient
298
341
  end
299
342
 
300
343
  def normalize_fail_spec(spec)
301
- spec.is_a?(String) ? { "path" => spec } : JSON.parse(JSON.generate(spec))
344
+ normalized = spec.is_a?(String) ? { "path" => spec } : JSON.parse(JSON.generate(spec))
345
+ normalized["chain"] = fail_chain(normalized["path"])
346
+ normalized
347
+ end
348
+
349
+ # A fail_at path as (field, indices) pairs: "people.0.pets.name" is people
350
+ # at index 0, then pets at any index, then name. An index you state has to
351
+ # match; one you leave out matches every position, so the plain
352
+ # "people.pets.name" fails the first element the walk reaches — which is
353
+ # what it has always done. Silently ignoring an index was the alternative,
354
+ # and a fail_at that never fires looks exactly like a passing test.
355
+ def fail_chain(path)
356
+ unless path.is_a?(String) && !path.empty?
357
+ raise ArgumentError, "fail_at: expected a response path like \"person.email\", got #{path.inspect}"
358
+ end
359
+
360
+ segments = path.split(".").map { |segment| segment.match?(/\A\d+\z/) ? Integer(segment) : segment }
361
+ if segments.first.is_a?(Integer)
362
+ raise ArgumentError, "fail_at: #{path.inspect} starts with a list index — a path starts with a field"
363
+ end
364
+
365
+ path_chain(segments)
366
+ end
367
+
368
+ # the shared fold: a fail_at path and the walk's own @path become the same
369
+ # shape, so one comparison serves both
370
+ def path_chain(segments)
371
+ segments.each_with_object([]) do |segment, chain|
372
+ field = chain.last
373
+ field && segment.is_a?(Integer) ? field.last << segment : chain << [segment, []]
374
+ end
302
375
  end
303
376
 
304
377
  # pins: the response keys an override pinned at this object, merged in as
@@ -433,16 +506,31 @@ class GraphWeaver::Testing::FakeClient
433
506
  case type.kind.name
434
507
  when "NON_NULL" then wire_value(type.of_type, value, coordinate)
435
508
  when "LIST"
436
- 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)
509
+ # whatever enumerates, not an Array alone: a has_many is an
510
+ # ActiveRecord CollectionProxy, and reading one straight onto the wire
511
+ # failed the cast as "the server sent a
512
+ # Order::ActiveRecord_Associations_CollectionProxy". A Hash is the one
513
+ # thing that enumerates and isn't a list.
514
+ return value if value.is_a?(Hash) || !value.is_a?(Enumerable)
515
+
516
+ value.map { |element| wire_value(type.of_type, element, coordinate) }
517
+ when "SCALAR" then scalar_wire(type.graphql_name, value, coordinate)
441
518
  when "ENUM" then value.is_a?(T::Enum) ? value.serialize : value
442
519
  else value # a composite: pinned_object reads it, one level down
443
520
  end
444
521
  end
445
522
 
523
+ # ID and String are JSON strings on every real wire, whatever Ruby type the
524
+ # object's column holds — an Integer primary key read straight through
525
+ # failed the cast with the advice for a server that sends ids unquoted,
526
+ # which is advice about a server that isn't there.
527
+ def scalar_wire(name, value, coordinate)
528
+ wired = @values.wire(name, value, coordinate)
529
+ return wired unless STRING_SCALARS.include?(name) && !wired.nil? && !wired.is_a?(String)
530
+
531
+ wired.to_s
532
+ end
533
+
446
534
  # The concrete type a pinned object is fabricated as. At a union or
447
535
  # interface the pin has to say: picking a member at random would fabricate
448
536
  # a shape the pinned keys don't fit, in whichever fraction of runs the
@@ -506,24 +594,55 @@ class GraphWeaver::Testing::FakeClient
506
594
  end
507
595
  end
508
596
 
509
- # first untriggered fail_at spec whose field chain (indices stripped)
510
- # matches where we are
597
+ # first untriggered fail_at spec whose chain matches where we are
511
598
  def matching_failure
512
- chain = @path.reject { |segment| segment.is_a?(Integer) }.join(".")
513
- @fail_at.find { |spec| !spec["triggered"] && spec["path"] == chain }
599
+ here = path_chain(@path)
600
+ @fail_at.find { |spec| !spec["triggered"] && at?(spec["chain"], here) }
601
+ end
602
+
603
+ def at?(chain, here)
604
+ return false unless chain.size == here.size
605
+
606
+ chain.zip(here).all? do |(field, indices), (at, positions)|
607
+ field == at && indices.each_with_index.all? { |index, depth| positions[depth] == index }
608
+ end
514
609
  end
515
610
 
516
611
  # honor pagination-ish arg semantics: first/last/limit caps the fabricated
517
612
  # list length, whether it arrives as a literal or as a variable
518
- def list_length(node)
613
+ def list_length(node, coordinate)
519
614
  argument = node.arguments.find { |arg| %w[first last limit].include?(arg.name) }
520
615
  capped = argument && argument_value(argument)
521
616
  # Array.new(-1) is "negative array size" out of the fabricator's guts; a
522
617
  # cap below zero asks for nothing, which is what a page of none is
523
618
  return [capped, 0].max if capped.is_a?(Integer)
619
+ return 0 if errors_list?(node.name)
524
620
 
621
+ size = list_size_for(coordinate, node.name)
525
622
  # 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
623
+ size.is_a?(Range) ? rng.rand(size) : size
624
+ end
625
+
626
+ # A list field whose name ends in `errors` fabricates empty. The Relay and
627
+ # Shopify payload convention — `placeOrder { order userErrors }` — otherwise
628
+ # comes back with a fabricated order AND a fabricated failure, which is a
629
+ # response no server can send, so the natural happy-path assertion is flaky
630
+ # until it is pinned. Pin it to fabricate the failure path.
631
+ def errors_list?(name) = name.downcase.end_with?("errors")
632
+
633
+ # How long an unbounded list is. A Hash says it per list, read most
634
+ # specific first like a pin — which is what keeps nested lists from
635
+ # multiplying: every list the walk reaches re-reads this, so one number
636
+ # for all of them is n rows x n tags.
637
+ def list_size_for(coordinate, name)
638
+ return @list_size unless @list_size.is_a?(Hash)
639
+
640
+ @list_size.fetch(coordinate) do
641
+ @list_size.fetch(name) do
642
+ @list_size.fetch(GraphWeaver::Internal::Overrides::LIST_SIZE_DEFAULT,
643
+ GraphWeaver::Testing::Config::DEFAULT_LIST_SIZE)
644
+ end
645
+ end
527
646
  end
528
647
 
529
648
  def type_value(type, node, selections, coordinate: nil, non_null: false)
@@ -536,7 +655,7 @@ class GraphWeaver::Testing::FakeClient
536
655
 
537
656
  case type.kind.name
538
657
  when "LIST"
539
- elements = Array.new(list_length(node)) do |index|
658
+ elements = Array.new(list_length(node, coordinate)) do |index|
540
659
  @path.push(index)
541
660
  element = type_value(type.of_type, node, selections, coordinate:)
542
661
  @path.pop