graph_weaver 0.7.0 → 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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +4 -4
  3. data/README.md +40 -88
  4. data/docs/alternatives.md +1 -7
  5. data/docs/cassettes.md +54 -59
  6. data/docs/editors.md +32 -47
  7. data/docs/errors.md +261 -369
  8. data/docs/federation.md +650 -837
  9. data/docs/generated_modules.md +370 -459
  10. data/docs/getting_started.md +211 -428
  11. data/docs/i18n.md +114 -177
  12. data/docs/logging.md +127 -116
  13. data/docs/real_world.md +26 -39
  14. data/docs/scalars.md +277 -310
  15. data/docs/testing.md +340 -486
  16. data/docs/transports.md +191 -263
  17. data/docs/upgrading.md +188 -560
  18. data/examples/README.md +38 -0
  19. data/examples/countries.rb +39 -0
  20. data/examples/federation.rb +62 -0
  21. data/examples/github/generate.rb +20 -0
  22. data/examples/github/generated/star_mutation.rb +126 -0
  23. data/examples/github/generated/stargazers_query.rb +232 -0
  24. data/examples/github/generated/starred_query.rb +151 -0
  25. data/examples/github/queries/star.graphql +8 -0
  26. data/examples/github/queries/stargazers.graphql +22 -0
  27. data/examples/github/queries/starred.graphql +11 -0
  28. data/examples/github/run.rb +43 -0
  29. data/examples/github/setup.rb +18 -0
  30. data/examples/rick_and_morty.rb +57 -0
  31. data/graph_weaver.gemspec +12 -3
  32. data/lib/graph_weaver/client.rb +22 -1
  33. data/lib/graph_weaver/codegen.rb +5 -1
  34. data/lib/graph_weaver/context_seam.rb +54 -0
  35. data/lib/graph_weaver/errors.rb +23 -15
  36. data/lib/graph_weaver/federation.rb +11 -2
  37. data/lib/graph_weaver/in_process.rb +15 -9
  38. data/lib/graph_weaver/internal/endpoint.rb +7 -5
  39. data/lib/graph_weaver/internal/headers.rb +19 -0
  40. data/lib/graph_weaver/internal.rb +66 -13
  41. data/lib/graph_weaver/log_subscriber.rb +10 -2
  42. data/lib/graph_weaver/logging.rb +33 -13
  43. data/lib/graph_weaver/query_module.rb +8 -0
  44. data/lib/graph_weaver/retry.rb +12 -8
  45. data/lib/graph_weaver/schema_loader.rb +52 -14
  46. data/lib/graph_weaver/testing/cassette.rb +28 -5
  47. data/lib/graph_weaver/testing/endpoint.rb +14 -13
  48. data/lib/graph_weaver/testing/fake_client.rb +33 -3
  49. data/lib/graph_weaver/testing/router.rb +7 -3
  50. data/lib/graph_weaver/transport/http.rb +2 -2
  51. data/lib/graph_weaver/transport.rb +47 -23
  52. data/lib/graph_weaver/version.rb +1 -1
  53. data/lib/graph_weaver.rb +22 -1
  54. metadata +16 -3
  55. data/CHANGELOG.md +0 -3801
@@ -1,6 +1,12 @@
1
1
  # typed: ignore — ActiveSupport::LogSubscriber, which sorbet can't resolve here
2
2
  # frozen_string_literal: true
3
3
 
4
+ # The superclass, so this file stands alone: requiring it by hand is the
5
+ # documented way to subscribe outside Rails, and there is no railtie out there
6
+ # to have loaded ActiveSupport first. Skipped when the constant already
7
+ # exists, which is how a stand-in can take its place.
8
+ require "active_support/log_subscriber" unless defined?(ActiveSupport::LogSubscriber)
9
+
4
10
  module GraphWeaver
5
11
  # One line per GraphQL operation in a Rails log, the shape ActiveRecord
6
12
  # uses for a query:
@@ -54,11 +60,13 @@ module GraphWeaver
54
60
  payload[:graph] ? "#{payload[:graph]}/#{operation}" : operation
55
61
  end
56
62
 
57
- # status, then whatever narrows it: the error class, the code an alert
63
+ # status, then whatever narrows it: the error class, the reason an alert
58
64
  # groups by, and which attempt this was when a Retry is in the stack.
59
65
  def outcome(payload)
60
66
  parts = [payload[:status], payload[:error]]
61
- parts << "[#{payload[:code]}]" if payload[:code]
67
+ # the GraphQL code, or the HTTP status where the request never got one
68
+ reason = payload[:code] || (payload[:http_status] if payload[:status] == :failed)
69
+ parts << "[#{reason}]" if reason
62
70
  parts << "(retry #{payload[:retries]})" if payload[:retries].to_i.positive?
63
71
  parts.compact.join(" ")
64
72
  end
@@ -53,7 +53,7 @@ module GraphWeaver
53
53
  #
54
54
  # It must call the block and return its value. The only event today is
55
55
  # EXECUTE_EVENT; its payload is the contract in docs/logging.md —
56
- # :operation, :client, :status, :duration_ms, :graph always;
56
+ # :operation, :client, :kind, :status, :duration_ms, :graph always;
57
57
  # :url/:http_status over the wire, :schema in-process, :error/:code on a
58
58
  # failure, :retries when a Retry wrapped it. Never the query text or the
59
59
  # variables: the payload fans out to subscribers that know none of the
@@ -77,6 +77,15 @@ module GraphWeaver
77
77
  !key.nil? && Log.filter_variables({ key.to_s => nil })[key.to_s] == FILTERED
78
78
  end
79
79
 
80
+ # True when this key names a credential — asked of a url's query
81
+ # parameters, which are scrubbed whatever the app's logging appetite.
82
+ # filter_parameters is a knob about log verbosity; emptying it must
83
+ # not un-scrub a token in an endpoint, any more than it un-scrubs the
84
+ # url's userinfo. The app's list widens this; it can't narrow it.
85
+ def credential?(key)
86
+ filtered?(key) || Log.filtered?(key, GraphWeaver::DEFAULT_FILTER_PARAMETERS)
87
+ end
88
+
80
89
  # `detail` unless the key is filtered — free text a coercer or sorbet
81
90
  # wrote can spell a value any way, so for a filtered key none of it
82
91
  # survives, not the parts that would have been safe.
@@ -94,12 +103,18 @@ module GraphWeaver
94
103
  # optional because a coercer refusing a value hasn't been told one.
95
104
  def shown(raw, key = nil) = filtered?(key) ? FILTERED : cap(value(key, raw).inspect)
96
105
 
106
+ # A short server-chosen string the library republishes inside its own
107
+ # text — the APM's :code, the [CODE] in the one line info writes, a
108
+ # redirect's destination. Control characters are stripped because it
109
+ # lands where the log's own framing lives: a newline in
110
+ # extensions.code forges a second, complete-looking line.
111
+ def tag(value) = value.is_a?(String) ? cap(value.gsub(/[[:cntrl:]]+/, " ")) : value
112
+
97
113
  # Text the library didn't author — a value a caller sent, a sentence a
98
114
  # server wrote — cut to what an error may carry. The number lives on
99
115
  # InputError, which is the class that documents it and the one every
100
116
  # capped string reaches.
101
- def cap(text)
102
- limit = GraphWeaver::InputError::VALUE_LIMIT
117
+ def cap(text, limit = GraphWeaver::InputError::VALUE_LIMIT)
103
118
  return text if text.bytesize <= limit
104
119
 
105
120
  # byteslice can land mid-character; scrub drops the partial tail
@@ -183,13 +198,15 @@ module GraphWeaver
183
198
  payload[:status] = :ok
184
199
  else
185
200
  payload[:status] = :errors
186
- payload[:code] = errors.grep(Hash).filter_map { |e| GraphWeaver::GraphQLError.from_h(e).code }.first
201
+ code = errors.grep(Hash).filter_map { |e| GraphWeaver::GraphQLError.from_h(e).code }.first
202
+ payload[:code] = Redact.tag(code)
187
203
  end
188
204
  result
189
205
  rescue => e
190
206
  payload[:error] = e.class.name
191
- # the one key an alert groups by, whichever kind of failure it was
192
- payload[:code] = e.status if e.is_a?(GraphWeaver::ServerError)
207
+ # :code stays the GraphQL error code and nothing else it used
208
+ # to hold a ServerError's status here, so one tag carried two
209
+ # dimensions ("THROTTLED" and 429). The number is :http_status.
193
210
  raise
194
211
  ensure
195
212
  payload[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
@@ -249,6 +266,16 @@ module GraphWeaver
249
266
  filters.nil? ? variables : filters.filter(variables)
250
267
  end
251
268
 
269
+ # Whether a key matches one of `filters` — the same matching the
270
+ # variables line uses, asked about a list other than the app's so
271
+ # Redact.credential? can hold url parameters to the default names.
272
+ def filtered?(key, filters)
273
+ name = key.to_s
274
+ filters.any? do |filter|
275
+ filter.is_a?(Regexp) ? name.match?(filter) : name.downcase.include?(filter.to_s.downcase)
276
+ end
277
+ end
278
+
252
279
  private
253
280
 
254
281
  # The GraphQL errors a response carries, whatever answered it — a
@@ -269,13 +296,6 @@ module GraphWeaver
269
296
  else value
270
297
  end
271
298
  end
272
-
273
- def filtered?(key, filters)
274
- name = key.to_s
275
- filters.any? do |filter|
276
- filter.is_a?(Regexp) ? name.match?(filter) : name.downcase.include?(filter.to_s.downcase)
277
- end
278
- end
279
299
  end
280
300
  end
281
301
  end
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "sorbet-runtime"
5
5
 
6
+ require_relative "internal"
6
7
  require_relative "internal/test_clients"
7
8
 
8
9
  module GraphWeaver
@@ -44,6 +45,13 @@ module GraphWeaver
44
45
  # the whole of the call instead of three arguments' worth of it.
45
46
  sig { params(variables: T::Hash[String, T.untyped], client: T.untyped).returns(T.untyped) }
46
47
  def dispatch(variables, client:)
48
+ # A value with no JSON form is a bug in the call, not in the client that
49
+ # would have carried it — so it is refused here, where every mode passes,
50
+ # rather than in the transport, which :in_process and :fake never reach.
51
+ # (A transport asks the same question of a raw query string, which never
52
+ # comes through here.)
53
+ GraphWeaver::Internal::Wire.check_variables!(variables)
54
+
47
55
  mod = T.unsafe(self)
48
56
  # the graph codegen baked in, never one inferred from the client — a
49
57
  # wrong label on a request is worse than no label
@@ -38,7 +38,8 @@ require_relative "transport"
38
38
  # default — pass the codes your API uses for transient failures)
39
39
  #
40
40
  # A server that answers with Retry-After sets the delay itself (clamped
41
- # to max_delay:); otherwise the configured backoff decides.
41
+ # to max_delay:) whether that answer raised or came back as a response
42
+ # carrying GraphQL errors; otherwise the configured backoff decides.
42
43
  #
43
44
  # Exhausting the retries re-raises the last error (or returns the last
44
45
  # code-matched response).
@@ -105,10 +106,10 @@ class GraphWeaver::Retry
105
106
  def execute(query, variables: {}, operation_name: nil)
106
107
  attempts = mutation?(query) ? 1 : @retries + 1
107
108
  attempt = 0
108
- failure = T.let(nil, T.nilable(Exception))
109
109
 
110
110
  loop do
111
111
  attempt += 1
112
+ failure = nil
112
113
  begin
113
114
  # each attempt is its own EXECUTE_EVENT; :retries says which one,
114
115
  # so "slow" and "slow after two 502s" don't read the same in an APM
@@ -116,6 +117,8 @@ class GraphWeaver::Retry
116
117
  @client.execute(query, variables:, operation_name:)
117
118
  end
118
119
  return response unless attempt < attempts && retryable_response?(response)
120
+
121
+ failure = response
119
122
  rescue *@retry_on => e
120
123
  if attempt >= attempts || !@retry_if.call(e)
121
124
  GraphWeaver::Internal::Log.log(:warn) { MUTATION_HINT } if attempts == 1 && @retries.positive?
@@ -132,7 +135,6 @@ class GraphWeaver::Retry
132
135
  "retrying #{operation_name || "query"} in #{seconds.round(2)}s (attempt #{attempt + 1} of #{attempts})"
133
136
  end
134
137
  @sleeper.call(seconds)
135
- failure = nil
136
138
  end
137
139
  end
138
140
 
@@ -163,11 +165,13 @@ class GraphWeaver::Retry
163
165
  end
164
166
 
165
167
  def delay(attempt, failure)
166
- # A Retry-After wins over our backoff: the server is the only party
167
- # that knows when its window reopens, and it isn't guessing. Still
168
- # clamped to max_delay:, so "come back in an hour" can't park a thread for
169
- # an hour and not jittered, since it's an instruction, not a guess.
170
- after = failure.retry_after if failure.is_a?(GraphWeaver::ServerError)
168
+ # A Retry-After wins over our backoff, however the failure arrived a
169
+ # raised ServerError, or the envelope a rate limit that came back with an
170
+ # errors body makes. The server is the only party that knows when its
171
+ # window reopens, and it isn't guessing. Still clamped to max_delay:, so
172
+ # "come back in an hour" can't park a thread for an hour — and not
173
+ # jittered, since it's an instruction, not a guess.
174
+ after = failure.retry_after if failure.respond_to?(:retry_after)
171
175
  return [after, @max_delay].min.to_f if after
172
176
 
173
177
  # floored at 0: a custom backoff: is the caller's arithmetic, and a
@@ -1,6 +1,7 @@
1
1
  # typed: true
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "digest"
4
5
  require "fileutils"
5
6
  require "graphql"
6
7
  require "json"
@@ -682,7 +683,7 @@ module GraphWeaver::SchemaLoader
682
683
  # end
683
684
  # schema = GraphWeaver::SchemaLoader.load(json)
684
685
  def self.introspect(transport, cache: nil, ttl: nil, auth_env: nil)
685
- cache = cache_path(cache)
686
+ cache = cache_path(cache, (transport.url if transport.respond_to?(:url)))
686
687
 
687
688
  if cache
688
689
  # reuse whatever fresh dump is present, regardless of format —
@@ -749,16 +750,20 @@ module GraphWeaver::SchemaLoader
749
750
  schema
750
751
  end
751
752
 
752
- # The introspection result, asking for isOneOf and falling back without it.
753
+ # The introspection result, asking for the newer fields and falling back
754
+ # without them.
753
755
  #
754
756
  # isOneOf is the only thing that says an input object is @oneOf, and
755
- # graphql-ruby leaves it out unless asked but it is newer than plenty of
756
- # servers, and one that doesn't define it REFUSES the query outright
757
- # (Hasura: "field 'isOneOf' not found in type: '__Type'"). So ask, and ask
758
- # the baseline query rather than give up. The second request costs one round
759
- # trip on exactly the servers whose answer was going to be an error anyway.
757
+ # specifiedByURL the only thing that says what a scalar's format is;
758
+ # graphql-ruby leaves both out unless asked but they are newer than
759
+ # plenty of servers, and one that doesn't define them REFUSES the query
760
+ # outright (Hasura: "field 'isOneOf' not found in type: '__Type'"). So ask,
761
+ # and ask the baseline query rather than give up. The second request costs
762
+ # one round trip on exactly the servers whose answer was going to be an
763
+ # error anyway.
760
764
  def self.ask(transport)
761
- result = transport.execute(GraphQL::Introspection.query(include_is_one_of: true), variables: {}).to_h
765
+ query = GraphQL::Introspection.query(include_is_one_of: true, include_specified_by_url: true)
766
+ result = transport.execute(query, variables: {}).to_h
762
767
  return result if result["errors"].nil? && result.dig("data", "__schema")
763
768
 
764
769
  transport.execute(GraphQL::Introspection.query, variables: {}).to_h
@@ -948,23 +953,24 @@ module GraphWeaver::SchemaLoader
948
953
  private_constant :CACHE_EXTENSIONS
949
954
 
950
955
  # cache: true / :json / :graphql / :gql / a path => the file to write
951
- # (nil for no caching). Symbols and true anchor at GraphWeaver.schema_path —
952
- # the schema dump the generation workflow reads, so one file serves both
953
- # (introspect caches it, rake generate loads it).
954
- def self.cache_path(cache)
956
+ # (nil for no caching). A path is taken as given. Symbols and true anchor at
957
+ # GraphWeaver.schema_path — the schema dump the generation workflow reads,
958
+ # so one file serves both (introspect caches it, rake generate loads it)
959
+ # unless that dump belongs to a different endpoint; see {conventional_dump}.
960
+ def self.cache_path(cache, url = nil)
955
961
  # Rails.root.join(...) hands you a Pathname, as schema: and query: already take
956
962
  cache = cache.to_path if cache.respond_to?(:to_path)
957
963
  path = case cache
958
964
  when nil, false
959
965
  nil
960
966
  when true
961
- GraphWeaver.schema_path
967
+ conventional_dump(File.extname(GraphWeaver.schema_path), url)
962
968
  when Symbol
963
969
  unless CACHE_EXTENSIONS.include?(".#{cache}")
964
970
  raise ArgumentError, "cache: format must be :json, :graphql, or :gql, got #{cache.inspect}"
965
971
  end
966
972
 
967
- "#{strip_extension(GraphWeaver.schema_path)}.#{cache}"
973
+ conventional_dump(".#{cache}", url)
968
974
  else
969
975
  unless cache.end_with?(*CACHE_EXTENSIONS)
970
976
  raise ArgumentError, "cache: must be a .json or .graphql/.gql path, got #{cache}"
@@ -976,6 +982,38 @@ module GraphWeaver::SchemaLoader
976
982
  end
977
983
  private_class_method :cache_path
978
984
 
985
+ # Where an unnamed `cache:` lands. One rule: **the conventional dump, unless
986
+ # the dump there came from a different endpoint** — then a file of this
987
+ # client's own, named by a digest of its url and sitting beside it. A dump
988
+ # records the url it was introspected from, so this is answerable; without
989
+ # it, two clients at two origins both saying `cache: true` read and
990
+ # overwrote one file, and each was silently served the other's schema.
991
+ #
992
+ # A dump that records no url is nobody's in particular — hand-written, or
993
+ # committed by an older version — so it stays a hit for whoever asks.
994
+ def self.conventional_dump(extension, url)
995
+ base = strip_extension(GraphWeaver.schema_path)
996
+ conventional = base + extension
997
+ url = url && GraphWeaver::Internal::Endpoint.bare(url) # what a dump records
998
+ return conventional if url.nil? || !someone_elses?(conventional, url)
999
+
1000
+ "#{base}-#{Digest::SHA256.hexdigest(url)[0, 8]}#{extension}"
1001
+ end
1002
+ private_class_method :conventional_dump
1003
+
1004
+ # Whether a dump already at `path` (or a sibling format) says it came from
1005
+ # somewhere other than `url`. Nothing there yet is nobody's, so a cold
1006
+ # client still writes the conventional dump — which is what the documented
1007
+ # bootstrap does, and what an app with one client wants.
1008
+ def self.someone_elses?(path, url)
1009
+ existing = cache_candidates(path).find { |candidate| File.exist?(candidate) }
1010
+ return false unless existing
1011
+
1012
+ recorded = provenance(existing)&.dig("url")
1013
+ !recorded.nil? && recorded != url
1014
+ end
1015
+ private_class_method :someone_elses?
1016
+
979
1017
  # the requested path first, then its siblings in the other formats
980
1018
  def self.cache_candidates(path)
981
1019
  path = GraphWeaver::Internal::Util.resolve(path)
@@ -115,11 +115,8 @@ module GraphWeaver
115
115
 
116
116
  def initialize(path)
117
117
  @path = Testing.cassette_path(path)
118
- @entries = File.exist?(@path) ? YAML.safe_load_file(@path, aliases: true) : []
118
+ @entries = read_entries
119
119
  @flagged = []
120
- # record is read-modify-write; two threads recording through one
121
- # cassette (a parallel spec run) would each save a snapshot missing
122
- # the other's entry — atomic_write keeps the file whole, not complete
123
120
  @lock = Mutex.new
124
121
  end
125
122
 
@@ -147,7 +144,13 @@ module GraphWeaver
147
144
  entry["response"] = response
148
145
 
149
146
  wanted = Internal::RequestKey.for(query, variables, operation_name)
150
- @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
151
154
  @entries.reject! { |existing| Internal::RequestKey.for_entry(existing) == wanted }
152
155
  @entries << entry
153
156
  save
@@ -198,6 +201,26 @@ module GraphWeaver
198
201
 
199
202
  private
200
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
+
201
224
  def save
202
225
  yaml = YAML.dump(@entries)
203
226
  FileUtils.mkdir_p(File.dirname(@path))
@@ -27,6 +27,12 @@ module GraphWeaver
27
27
  # one thing an in-process client can't test.
28
28
  #
29
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.
30
36
  class Endpoint
31
37
  JSON_HEADERS = { "content-type" => "application/json" }.freeze
32
38
  TEXT_HEADERS = { "content-type" => "text/plain" }.freeze
@@ -67,20 +73,15 @@ module GraphWeaver
67
73
 
68
74
  private
69
75
 
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)
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)
77
83
 
78
- @client.context = context.call(headers)
79
- begin
80
- yield
81
- ensure
82
- @client.context = context
83
- end
84
+ @client.with_request_context(headers, &block)
84
85
  end
85
86
 
86
87
  # Rack spells a header HTTP_X_CALLER; the proc reads "X-Caller".
@@ -147,7 +147,11 @@ class GraphWeaver::Testing::FakeClient
147
147
  # `hash` or a `count`, and a Struct answers both with plausible nonsense
148
148
  # where fabricating is right.
149
149
  RUBY_OWN = [BasicObject, Kernel, Object, Comparable, Enumerable, Struct, Data].freeze
150
- private_constant :OPTIONS, :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
151
155
 
152
156
  def initialize(pins = {}, **options)
153
157
  config = GraphWeaver::Testing.config
@@ -502,13 +506,31 @@ class GraphWeaver::Testing::FakeClient
502
506
  case type.kind.name
503
507
  when "NON_NULL" then wire_value(type.of_type, value, coordinate)
504
508
  when "LIST"
505
- value.is_a?(Array) ? value.map { |element| wire_value(type.of_type, element, coordinate) } : value
506
- when "SCALAR" then @values.wire(type.graphql_name, value, coordinate)
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)
507
518
  when "ENUM" then value.is_a?(T::Enum) ? value.serialize : value
508
519
  else value # a composite: pinned_object reads it, one level down
509
520
  end
510
521
  end
511
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
+
512
534
  # The concrete type a pinned object is fabricated as. At a union or
513
535
  # interface the pin has to say: picking a member at random would fabricate
514
536
  # a shape the pinned keys don't fit, in whichever fraction of runs the
@@ -594,12 +616,20 @@ class GraphWeaver::Testing::FakeClient
594
616
  # Array.new(-1) is "negative array size" out of the fabricator's guts; a
595
617
  # cap below zero asks for nothing, which is what a page of none is
596
618
  return [capped, 0].max if capped.is_a?(Integer)
619
+ return 0 if errors_list?(node.name)
597
620
 
598
621
  size = list_size_for(coordinate, node.name)
599
622
  # an Integer list_size means exactly that many; a Range randomizes within it
600
623
  size.is_a?(Range) ? rng.rand(size) : size
601
624
  end
602
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
+
603
633
  # How long an unbounded list is. A Hash says it per list, read most
604
634
  # specific first like a pin — which is what keeps nested lists from
605
635
  # multiplying: every list the walk reaches re-reads this, so one number
@@ -11,6 +11,7 @@ require_relative "../internal"
11
11
  require_relative "../transport"
12
12
  require_relative "../internal/planner"
13
13
  require_relative "../internal/subgraphs"
14
+ require_relative "../context_seam"
14
15
 
15
16
  module GraphWeaver
16
17
  module Testing
@@ -203,6 +204,8 @@ module GraphWeaver
203
204
  # reset it yourself around the code path you're measuring.
204
205
  class Router
205
206
  include GraphWeaver::Parsing
207
+ # #context/#context= plus the lock over them — see the accessor below
208
+ include GraphWeaver::ContextSeam
206
209
 
207
210
  # the schema the router serves — the supergraph with its composition
208
211
  # machinery stripped, exactly what a real router exposes
@@ -232,8 +235,9 @@ module GraphWeaver
232
235
  # run as a different user without rebuilding the router. A proc is
233
236
  # answered from the request's headers, which only a wire supplies:
234
237
  # `context: ->(headers) { { current_user: User.find_by(token:
235
- # headers["Authorization"]) } }` served through {Endpoint}.
236
- attr_accessor :context
238
+ # headers["Authorization"]) } }` served through {Endpoint}. The
239
+ # accessors, and the lock guarding them, come from
240
+ # {GraphWeaver::ContextSeam}.
237
241
 
238
242
  # The planner injects key fields under this prefix, and the concrete
239
243
  # __typename under that key; reading an answer back means stripping
@@ -252,7 +256,7 @@ module GraphWeaver
252
256
  source = supergraph.to_s # a path, or the SDL itself — Pathname included
253
257
  @schema = GraphWeaver::SchemaLoader.load(source)
254
258
  @table = GraphWeaver::SchemaLoader.routing_table(source)
255
- @context = context
259
+ init_context_seam(context)
256
260
  @trace = []
257
261
 
258
262
  Unplannable.unsupported!(@table)
@@ -55,7 +55,7 @@ module GraphWeaver
55
55
  nil
56
56
  end
57
57
  unless uri && %w[http https].include?(uri.scheme)
58
- raise ArgumentError, "expected an http(s) url, got #{url.inspect}"
58
+ raise ArgumentError, "expected an http(s) url, got #{GraphWeaver::Internal::Endpoint.safe(url).inspect}"
59
59
  end
60
60
 
61
61
  @uri = uri
@@ -69,7 +69,7 @@ module GraphWeaver
69
69
  # verify_mode: — so mTLS doesn't mean reaching for Faraday
70
70
  @ssl = { ca_file:, ca_path:, cert:, key:, verify_mode: }.compact
71
71
  if @ssl.any? && @uri.scheme != "https"
72
- raise ArgumentError, "TLS options need an https url — got #{url}"
72
+ raise ArgumentError, "TLS options need an https url — got #{GraphWeaver::Internal::Endpoint.safe(url)}"
73
73
  end
74
74
 
75
75
  # One permit per allowed socket: holding a permit is the right to