graph_weaver 0.7.0 → 0.7.2

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 (61) 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 +380 -463
  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 +343 -486
  16. data/docs/transports.md +203 -268
  17. data/docs/upgrading.md +211 -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 +30 -1
  33. data/lib/graph_weaver/codegen/emit.rb +5 -11
  34. data/lib/graph_weaver/codegen.rb +23 -55
  35. data/lib/graph_weaver/context_seam.rb +54 -0
  36. data/lib/graph_weaver/errors.rb +23 -15
  37. data/lib/graph_weaver/federation.rb +11 -2
  38. data/lib/graph_weaver/graph.rb +39 -29
  39. data/lib/graph_weaver/in_process.rb +15 -9
  40. data/lib/graph_weaver/internal/endpoint.rb +7 -5
  41. data/lib/graph_weaver/internal/headers.rb +19 -0
  42. data/lib/graph_weaver/internal/test_clients.rb +7 -11
  43. data/lib/graph_weaver/internal.rb +81 -13
  44. data/lib/graph_weaver/log_subscriber.rb +10 -2
  45. data/lib/graph_weaver/logging.rb +33 -13
  46. data/lib/graph_weaver/query_module.rb +44 -23
  47. data/lib/graph_weaver/retry.rb +12 -8
  48. data/lib/graph_weaver/rspec.rb +13 -24
  49. data/lib/graph_weaver/schema_loader.rb +52 -14
  50. data/lib/graph_weaver/tasks.rb +10 -2
  51. data/lib/graph_weaver/testing/cassette.rb +28 -5
  52. data/lib/graph_weaver/testing/endpoint.rb +14 -13
  53. data/lib/graph_weaver/testing/fake_client.rb +33 -3
  54. data/lib/graph_weaver/testing/router.rb +7 -3
  55. data/lib/graph_weaver/testing.rb +12 -4
  56. data/lib/graph_weaver/transport/http.rb +2 -2
  57. data/lib/graph_weaver/transport.rb +47 -23
  58. data/lib/graph_weaver/version.rb +1 -1
  59. data/lib/graph_weaver.rb +32 -10
  60. metadata +16 -3
  61. data/CHANGELOG.md +0 -3801
@@ -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
@@ -11,21 +12,21 @@ module GraphWeaver
11
12
  # Runtime for generated query modules: the client plumbing, which is the
12
13
  # one part of a generated module that carries no per-query type
13
14
  # information — every module's copy was identical. `extend
14
- # GraphWeaver::QueryModule` supplies `client`/`client=`; execute and
15
- # from_response stay generated, since their sigs are the query's types and
16
- # those are the point.
15
+ # GraphWeaver::QueryModule` supplies `client`; execute and from_response
16
+ # stay generated, since their sigs are the query's types and those are the
17
+ # point.
17
18
  #
18
- # Resolution order, per the docs: per call → per module (`MyQuery.client =`)
19
- # → a test mode's stand-in (Internal::TestClients) → the module's baked
20
- # DEFAULT_CLIENT → `GraphWeaver.client`.
19
+ # Resolution order, per the docs: per call → a test mode's stand-in
20
+ # (Internal::TestClients) → the client the module's graph names →
21
+ # `GraphWeaver.client`. A module has no fifth slot you can set: a parsed
22
+ # module runs against whatever parsed it (GraphWeaver.parse(client:)),
23
+ # which is a property of parsing rather than a per-module override.
21
24
  module QueryModule
22
25
  extend T::Sig
23
26
 
24
- sig { params(client: T.untyped).void }
25
- attr_writer :client
26
-
27
- # the default client (a GraphWeaver::Client or any transport) for
28
- # execute: per-module override, else the baked default, else the app one
27
+ # What this module would execute through, right now — the client a parse
28
+ # bound it to, else the order above. A diagnostic, and what `execute`
29
+ # reads when the call names none.
29
30
  sig { returns(T.untyped) }
30
31
  def client
31
32
  @client || default_client
@@ -33,6 +34,13 @@ module GraphWeaver
33
34
 
34
35
  private
35
36
 
37
+ # Bound by GraphWeaver.parse, which is the only caller: a parsed module
38
+ # generates no file, so it has no graph to read a client off. Private
39
+ # because a generated module's client comes from its graph — one way to
40
+ # say a thing.
41
+ sig { params(client: T.untyped).void }
42
+ attr_writer :client
43
+
36
44
  # The one call a generated `execute` makes: resolve the client, run this
37
45
  # module's own operation, hand the raw response back for from_response to
38
46
  # wrap. Here rather than emitted, so what has to BRACKET a request — the
@@ -44,12 +52,17 @@ module GraphWeaver
44
52
  # the whole of the call instead of three arguments' worth of it.
45
53
  sig { params(variables: T::Hash[String, T.untyped], client: T.untyped).returns(T.untyped) }
46
54
  def dispatch(variables, client:)
55
+ # A value with no JSON form is a bug in the call, not in the client that
56
+ # would have carried it — so it is refused here, where every mode passes,
57
+ # rather than in the transport, which :in_process and :fake never reach.
58
+ # (A transport asks the same question of a raw query string, which never
59
+ # comes through here.)
60
+ GraphWeaver::Internal::Wire.check_variables!(variables)
61
+
47
62
  mod = T.unsafe(self)
48
63
  # the graph codegen baked in, never one inferred from the client — a
49
64
  # wrong label on a request is worse than no label
50
- graph = mod.const_defined?(:GRAPH, false) ? mod.const_get(:GRAPH) : nil
51
-
52
- GraphWeaver::Internal::Log.with_graph(graph) do
65
+ GraphWeaver::Internal::Log.with_graph(graph_name) do
53
66
  client_for(client).execute(mod.const_get(:QUERY), variables:,
54
67
  operation_name: mod.const_get(:OPERATION_NAME))
55
68
  end
@@ -71,20 +84,28 @@ module GraphWeaver
71
84
  "#{self}: client must respond to #execute(query, variables:), got #{target.class}"
72
85
  end
73
86
 
74
- # Codegen's `client:` constant, emitted as a DEFAULT_CLIENT lambda so the
75
- # constant it names is resolved on first use rather than at load — a
76
- # generated file may load before the initializer that builds the client.
87
+ # A module knows which graph it belongs to, and the graph knows how to
88
+ # reach it: the client that graph names, else the app default. Read at
89
+ # call time, so renaming the constant a graph names is an initializer
90
+ # edit rather than a regeneration of every module.
77
91
  #
78
- # A test mode stands in for it: what codegen baked in is exactly what a
79
- # `graphql:` tag means to replace, so a bound module is covered by the
80
- # tag like every other one.
92
+ # A test mode stands in ahead of it: the graph's client is exactly what a
93
+ # `graphql:` tag means to replace, so a tagged example reaches a module
94
+ # whose graph names a client like every other one.
81
95
  sig { returns(T.untyped) }
82
96
  def default_client
83
- mod = T.unsafe(self)
84
- stand_in = GraphWeaver::Internal::TestClients.for(mod)
97
+ stand_in = GraphWeaver::Internal::TestClients.for(T.unsafe(self))
85
98
  return stand_in if stand_in
86
99
 
87
- mod.const_defined?(:DEFAULT_CLIENT, false) ? mod.const_get(:DEFAULT_CLIENT).call : GraphWeaver.client!
100
+ GraphWeaver::Internal::Util.graph_named(graph_name)&.client || GraphWeaver.client!
101
+ end
102
+
103
+ # The graph codegen baked in, by name — nil for a module generated before
104
+ # graphs existed, or by a GraphWeaver.parse that named none.
105
+ sig { returns(T.untyped) }
106
+ def graph_name
107
+ mod = T.unsafe(self)
108
+ mod.const_defined?(:GRAPH, false) ? mod.const_get(:GRAPH) : nil
88
109
  end
89
110
  end
90
111
  end
@@ -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
@@ -255,13 +255,12 @@ module GraphWeaver
255
255
  def self.unserve!(stub) = WebMock::StubRegistry.instance.request_stubs.delete(stub)
256
256
 
257
257
  # Every endpoint an example's modules can post to, each with the graph
258
- # whose resolvers belong behind it: the client each graph bakes into its
259
- # modules, or GraphWeaver.client for a graph baking none. One graph per
260
- # endpoint — an app whose graphs all bake clients needs no app default
261
- # at all.
258
+ # whose resolvers belong behind it: the client each graph names, or
259
+ # GraphWeaver.client for a graph naming none. One graph per endpoint —
260
+ # an app whose graphs all name clients needs no app default at all.
262
261
  def self.wire_targets
263
262
  targets = GraphWeaver.graphs.filter_map do |graph|
264
- client = baked_client(graph) || GraphWeaver.client
263
+ client = graph.client || GraphWeaver.client
265
264
  [endpoint!(client, graph), graph] if client
266
265
  end
267
266
  refuse_shared_endpoint!(targets)
@@ -283,24 +282,10 @@ module GraphWeaver
283
282
  raise GraphWeaver::Error, "#{TAG}: :wire serves one schema at each endpoint, and graphs " \
284
283
  "#{names} post to the same one (#{url}) — whichever were served there would answer the " \
285
284
  "others' queries, as fields its schema doesn't define. Give each graph a client of its " \
286
- "own (client: in the graph block), or tag the example #{TAG}: :in_process or " \
285
+ "own (`client` in the graph block), or tag the example #{TAG}: :in_process or " \
287
286
  "#{TAG}: :router, which run above the wire."
288
287
  end
289
288
 
290
- # The client a graph's generated modules call. `client:` holds a
291
- # constant or its name — codegen writes it into source — so a name is
292
- # resolved here the way the generated DEFAULT_CLIENT lambda resolves it.
293
- def self.baked_client(graph)
294
- named = graph.client
295
- return named unless named.is_a?(String)
296
-
297
- Object.const_get(named)
298
- rescue NameError
299
- raise GraphWeaver::Error, "#{TAG}: graph #{graph.name.inspect} bakes client: " \
300
- "#{named.inspect} into its modules and nothing defines that constant, so :wire can't " \
301
- "find the endpoint they post to."
302
- end
303
-
304
289
  # The endpoint a client posts to: a transport, a Retry around one, or a
305
290
  # Client that built one. `graph` says whose client it is, when it isn't
306
291
  # the app's own.
@@ -316,12 +301,16 @@ module GraphWeaver
316
301
  "#{TAG}: :in_process or #{TAG}: :router — they run above the wire."
317
302
  end
318
303
 
319
- # which client posts to nothing — the app's, or one graph's
304
+ # which client posts to nothing — the app's, or one graph's. A schema
305
+ # class in a client slot is named by ITS name: `client.class` is the
306
+ # word "Class", which names nothing anyone wrote.
320
307
  def self.whose_client(client, graph)
321
308
  return "GraphWeaver.client isn't set" unless client
322
- return "GraphWeaver.client is #{client.class}, which posts to none" unless graph&.name
323
309
 
324
- "graph #{graph.name.inspect} bakes client: #{client.class}, which posts to none"
310
+ named = client.is_a?(Module) ? client : client.class
311
+ return "GraphWeaver.client is #{named}, which posts to none" unless graph&.name
312
+
313
+ "graph #{graph.name.inspect} names client #{named}, which posts to none"
325
314
  end
326
315
 
327
316
  def self.webmock!
@@ -353,7 +342,7 @@ module GraphWeaver
353
342
  !WebMock::HttpLibAdapters::NetHttpAdapter::OriginalNetHTTP.equal?(Net::HTTP)
354
343
  end
355
344
 
356
- private_class_method :wire_targets, :refuse_shared_endpoint!, :baked_client, :whose_client,
345
+ private_class_method :wire_targets, :refuse_shared_endpoint!, :whose_client,
357
346
  :webmock!, :webmock_enabled?, :disclose!, :served, :unnamed_schemas, :loaded_schemas
358
347
 
359
348
  # Included into every example group, so graphql_context is there
@@ -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)
@@ -273,9 +273,17 @@ namespace :graph_weaver do
273
273
  puts "#{name} #{Array(graph.queries).join(", ")} -> #{GraphWeaver::Internal::Util.relative(graph.output)}"
274
274
  puts " namespace: #{graph.namespace}" if graph.namespace
275
275
  # which server a graph's modules call — the one thing this task couldn't
276
- # say. A graph that bakes none falls back to GraphWeaver.client, which is
276
+ # say. A graph that names none falls back to GraphWeaver.client, which is
277
277
  # an app-wide setting and not this task's subject.
278
- puts " client: #{graph.client}" if graph.client
278
+ #
279
+ # Reported rather than raised: this is the task you run to find out why a
280
+ # graph is wrong, so a client whose constant is missing is the answer,
281
+ # not a reason to stop listing the others.
282
+ begin
283
+ puts " client: #{graph.client_url || graph.client}" if graph.client
284
+ rescue GraphWeaver::Error => e
285
+ puts " client: #{e.message}"
286
+ end
279
287
  # a registration is scoped to one graph, and nothing else says which
280
288
  GraphWeaver::Internal::Tasks.registrations(graph).each { |line| puts line }
281
289
  end
@@ -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)
@@ -221,9 +221,10 @@ module GraphWeaver
221
221
  # warn line as it is constructed, and a predicate that raised to say
222
222
  # "no" put a refusal that never happened in the log of every :wire
223
223
  # example. The one that graph names, else config.router[:supergraph],
224
- # else the conventional dump when that's what it is. A client can't
225
- # supply one its schema is the API schema a router serves, with the
226
- # @join__* routing table stripped out.
224
+ # else the conventional dump when that's what it is, else the dump the
225
+ # app's own client was built from. A client's *schema* can't supply one
226
+ # — it is the API schema a router serves, with the @join__* routing
227
+ # table stripped out — but the file behind it carries the table.
227
228
  private def supergraph_for(graph)
228
229
  # named_schema?, so a graph that declared no schema of its own falls
229
230
  # through to config.router rather than past it to the conventional dump
@@ -232,7 +233,14 @@ module GraphWeaver
232
233
  return @router[:supergraph] if @router&.key?(:supergraph)
233
234
 
234
235
  path = GraphWeaver::SchemaLoader.locate_path
235
- path if path && GraphWeaver::Internal::Util.composed?(path)
236
+ return path if path && GraphWeaver::Internal::Util.composed?(path)
237
+
238
+ # Last, because codegen for the default graph reads the conventional
239
+ # dump, and the router must plan against what the modules were typed
240
+ # against.
241
+ client = GraphWeaver.client
242
+ source = client.schema_source if client.respond_to?(:schema_source)
243
+ source if source && GraphWeaver::Internal::Util.composed?(source)
236
244
  end
237
245
 
238
246
  # what to do about it, which differs by who asked: a graph in no
@@ -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