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
@@ -16,51 +16,67 @@ require_relative "testing"
16
16
  # it "authorizes drafts", graphql: :in_process do … end
17
17
  # describe "checkout", graphql: :router do … end
18
18
  #
19
+ # :live your app's own client, exactly as it is — the default,
20
+ # and how one example steps back out of a suite-wide
21
+ # config.default_mode
19
22
  # :fake fabricated, schema-correct data; no resolvers run
20
23
  # :in_process your resolvers, one live schema class, in-process
21
24
  # :router your resolvers, across a federated graph
22
- # false opt out GraphWeaver.client is left exactly as it is,
23
- # even under config.default_mode
25
+ # :wire your schema, served at your client's endpoint, so the
26
+ # transport you ship runs
24
27
  #
25
28
  # `rspec --tag graphql:router` runs one mode's examples.
26
29
  #
27
30
  # `GraphWeaver.client` is snapshotted before every example and restored
28
- # after — tagged, untagged, opted out, whatever the example did to it. So
29
- # an example (or a `before` block, or a shared context) is free to build
30
- # the client it wants:
31
+ # after — whatever its mode, and whatever the example did to it. So an
32
+ # untagged (:live) example, a `before` block, or a shared context is free to
33
+ # build the client it wants and have it cleaned up like a tagged one:
31
34
  #
32
- # before { GraphWeaver.client = GraphWeaver::Testing::Failure.throttled }
33
- # it "pins the name" { graphql_fake(overrides: { "Person.name" => "Ada" }) }
35
+ # it "backs off" { GraphWeaver.client = GraphWeaver::Testing::Failure.throttled }
34
36
  #
35
- # **Nothing needs configuring.** Each mode derives what it runs against,
36
- # and refuses naming what it looked for rather than guessing:
37
+ # **Nothing needs configuring.** Each mode derives what it runs against
38
+ # **per graph**, since with more than one the honest answer varies — and
39
+ # refuses, naming what it looked for, rather than guessing:
37
40
  #
38
41
  # - the schema is GraphWeaver::Testing.config.schema if you set one, else
39
- # the committed dump at GraphWeaver.schema_path, else the schema of
40
- # GraphWeaver.client.
41
- # - :in_process runs against config.schema, or the schema class your
42
- # client already uses. Only a live class has resolvers, so when
43
- # neither is there it says so rather than hunting for one.
44
- # - :router plans against the composed supergraph: the dump, when that's
45
- # what it is, else config.router = { supergraph: … }. Subgraphs are
46
- # derived from what each loaded schema defines; one nothing here serves
47
- # is absent, and only a query that reaches its fields is refused.
42
+ # the one that graph names, else the committed dump at
43
+ # GraphWeaver.schema_path, else the schema of GraphWeaver.client.
44
+ # - :in_process runs against config.schema, or the class that graph names,
45
+ # or the one your client already uses. Only a live class has resolvers,
46
+ # so when none is there it says so rather than hunting for one.
47
+ # - :router plans against the composed supergraph that graph names, else
48
+ # config.router = { supergraph: … }, else the dump when that's what it
49
+ # is and refuses a graph that is in none by name, rather than routing
50
+ # it into another graph's. Subgraphs are derived from what each loaded
51
+ # schema defines; one nothing here serves is absent, and only a query
52
+ # that reaches its fields is refused.
53
+ # - :wire serves what each graph IS, at the endpoint that graph's own
54
+ # client posts to, leaving every client in place so the real transport
55
+ # runs: its router when that graph is in a composed supergraph, its live
56
+ # schema class when it has one, else a fake of its schema — which is what
57
+ # an app that is a pure client of someone else's API has. Only a graph
58
+ # with no schema at all is refused. An app whose graphs all bake a
59
+ # `client:` needs no GraphWeaver.client at all. Needs webmock and rack
60
+ # (`require "webmock/rspec"`); webmock hooks Net::HTTP, Faraday and HTTPX.
48
61
  #
49
62
  # What it wires up:
50
63
  # - seed: defaults to rspec's --seed, so `rspec --seed 1234` reproduces
51
64
  # fake data along with test order
52
- # - a client per example, from the tag (or config.default_mode for an
53
- # untagged one; nil, the default, leaves GraphWeaver.client alone),
54
- # and GraphWeaver.client restored afterwards either way — so a client
55
- # an example builds for itself is cleaned up like a tagged one.
65
+ # - a stand-in per graph, from the tag (or config.default_mode for an
66
+ # untagged one; :live, the default, leaves every client alone), and
67
+ # GraphWeaver.client restored afterwards either way.
56
68
  # - graphql_context — the GraphQL context resolvers see, merged onto
57
- # config.context and reset between examples.
69
+ # config.context, reaching every stand-in the example runs through, and
70
+ # reset between examples.
58
71
  #
59
- # The router is built once for the suite (parsing a supergraph per example
60
- # would be real time) and installed for each.
72
+ # A helper graphql_fake, graphql_in_process, graphql_router is the
73
+ # stand-in for the modules of the graph `graph:` names, for this app's only
74
+ # graph when it names none, and refuses when there is none it can reach. Under
75
+ # :wire it is what gets SERVED behind that graph's endpoint, rather than what
76
+ # fills the client slot — which is how a :wire example pins its data.
61
77
  #
62
- # note: modules generated with a baked-in client: constant don't consult
63
- # GraphWeaver.client generate without client: to make them fakeable.
78
+ # A module generated with a baked-in client: is covered too — the mode
79
+ # stands in for that constant (Internal::TestClients).
64
80
  module GraphWeaver
65
81
  module Testing
66
82
  module RSpecIntegration
@@ -88,14 +104,32 @@ module GraphWeaver
88
104
  # former
89
105
  @__graph_weaver_tag = metadata[TAG] if metadata.key?(TAG)
90
106
  @__graph_weaver_mode = GraphWeaver::Testing::RSpecIntegration.mode_for(metadata)
91
- if @__graph_weaver_mode
92
- GraphWeaver.client = GraphWeaver::Testing::RSpecIntegration.client_for(@__graph_weaver_mode)
107
+ GraphWeaver::Internal::TestClients.install(@__graph_weaver_mode)
108
+ # :wire is the one mode that does NOT take the client slot — every
109
+ # client staying where it is is the whole point, so what the tag
110
+ # builds is served at each of their endpoints instead
111
+ @__graph_weaver_stubs = nil
112
+ if @__graph_weaver_mode == :wire
113
+ @__graph_weaver_stubs = GraphWeaver::Testing::RSpecIntegration.serve!
114
+ else
115
+ # one graph, one answer — so the app's client slot holds it too,
116
+ # the same object that graph's modules resolve; with several it
117
+ # holds a refusal, since the real client there is a live request
118
+ # waiting to happen. :live leaves the slot alone (app_client nil).
119
+ app_client = GraphWeaver::Internal::TestClients.app_client
120
+ GraphWeaver.client = app_client if app_client
93
121
  end
94
122
  end
95
123
 
96
124
  rspec_config.after(:each) do
125
+ # first, so a refused tag still tears the mode down
126
+ GraphWeaver::Internal::TestClients.reset!
97
127
  next unless defined?(@__graph_weaver_prior_client)
98
128
 
129
+ if defined?(@__graph_weaver_stubs) && @__graph_weaver_stubs
130
+ @__graph_weaver_stubs.each { |stub| GraphWeaver::Testing::RSpecIntegration.unserve!(stub) }
131
+ end
132
+ remove_instance_variable(:@__graph_weaver_stubs) if defined?(@__graph_weaver_stubs)
99
133
  GraphWeaver.client = @__graph_weaver_prior_client
100
134
  remove_instance_variable(:@__graph_weaver_prior_client)
101
135
  # a refused tag raises before the mode is ever set, and its message
@@ -105,39 +139,249 @@ module GraphWeaver
105
139
  end
106
140
  end
107
141
 
108
- # the mode this example's metadata selects, or the configured default
142
+ # the mode this example's metadata selects, or the configured default.
143
+ # Every example has exactly one — an untagged one's is config.default_mode,
144
+ # which is :live unless the suite set another.
109
145
  def self.mode_for(metadata, config = GraphWeaver::Testing.config)
110
146
  tagged = metadata[TAG]
111
147
  return config.default_mode if tagged.nil?
112
- # opt out: no client is installed, and a configured default_mode
113
- # doesn't sweep this example up
114
- return if tagged == false
115
148
 
116
149
  mode = tagged.to_s.to_sym
117
150
  return mode if CLIENT_MODES.include?(mode)
118
151
 
119
152
  raise GraphWeaver::Error, "#{TAG}: #{tagged.inspect} is not a mode — " \
120
- "#{CLIENT_MODES.map(&:inspect).join(", ")} (or false to opt out)"
153
+ "#{CLIENT_MODES.map(&:inspect).join(", ")}. :live leaves GraphWeaver.client exactly " \
154
+ "as it is, which is how one example steps back out of config.default_mode."
121
155
  end
122
156
 
123
- # the client an example in this mode runs against
124
- def self.client_for(mode, config = GraphWeaver::Testing.config)
125
- case mode
126
- when :fake
127
- FakeClient.new(schema: config.reference_schema!)
128
- when :in_process
129
- GraphWeaver::InProcess.new(config.schema_class!, context: config.context)
130
- when :router
131
- router = config.built_router
132
- router.context = config.context
133
- # built once for the suite, so it has to be told where this example
134
- # starts — the trace, and any faked subgraph's fabricated data
135
- router.reset!
157
+ # Serve each graph's resolvers at the endpoint its own client posts to,
158
+ # so every module an example can reach crosses a real wire — not just
159
+ # the ones posting to GraphWeaver.client. Returns the stubs; {unserve!}
160
+ # takes one back down after the example, and nothing else about the
161
+ # suite's WebMock setup is touched.
162
+ def self.serve!
163
+ webmock!
164
+ wire_targets.map do |url, graph|
165
+ # built here, so a graph with nothing to serve refuses before the
166
+ # example runs rather than from inside its first request
167
+ disclose!(GraphWeaver::Internal::TestClients.standin(graph), url, graph)
168
+ stub = WebMock::API.stub_request(:post, url)
169
+ # and read again per request: a graphql_* helper in the example body
170
+ # runs after this hook, and a pin that never reached the served
171
+ # endpoint would leave the example green and wrong. Through the
172
+ # stand-in table either way, so graphql_context reaches what is
173
+ # served here as it reaches every other mode's client.
174
+ #
175
+ # to_rack returns the stub's response list, not the stub, so the
176
+ # handle unserve! needs is the one stub_request handed back
177
+ stub.to_rack(lambda do |env|
178
+ client = GraphWeaver::Internal::TestClients.standin(graph)
179
+ GraphWeaver::Testing::Endpoint.new(client).call(env)
180
+ end)
181
+ stub
182
+ end
183
+ end
184
+
185
+ # Say what went behind this endpoint. :wire is the one tag that picks
186
+ # from three candidates, and the pick is invisible from the example —
187
+ # an app that owns resolvers can be served a fake and pass against
188
+ # fabricated data. So it narrates, on the logger a Rails app already
189
+ # has (the railtie wires Rails.logger).
190
+ #
191
+ # graph_weaver: :wire serving Shop::Schema (in-process) at http://…
192
+ #
193
+ # At warn, with the advice, when a fake stood in while this process
194
+ # HAS a schema class and nothing named it — a warning rather than a
195
+ # refusal because a loaded class isn't proof the app meant it here (a
196
+ # federated suite loads every subgraph's), and because serve! runs
197
+ # before the example body, so `graphql_fake` has no way to say "on
198
+ # purpose" in time to be heard.
199
+ def self.disclose!(client, url, graph)
200
+ unnamed = unnamed_schemas(graph) if client.is_a?(GraphWeaver::Testing::FakeClient)
201
+ if unnamed&.any?
202
+ GraphWeaver::Internal::Log.log(:warn) do
203
+ ":wire serving #{served(client)} at #{url} — #{unnamed.join(", ")} " \
204
+ "#{unnamed.one? ? "is" : "are"} loaded and nothing named #{unnamed.one? ? "it" : "one"}, " \
205
+ "so your resolvers did not run. To serve them, name it: " \
206
+ "GraphWeaver::Testing.config.schema = #{unnamed.first}"
207
+ end
208
+ else
209
+ GraphWeaver::Internal::Log.log(:info) { ":wire serving #{served(client)} at #{url}" }
136
210
  end
137
211
  end
138
212
 
139
- # included into every example group, so graphql_context is there
140
- # whether or not this example took a client from the hook
213
+ # What the stand-in IS, read off the object rather than re-deciding —
214
+ # one answer, and it can't drift from what was built. A fake of a dump
215
+ # has no name to give: the dump loads as an anonymous class, and
216
+ # guessing which file it came from would be a label that can be wrong.
217
+ def self.served(client)
218
+ case client
219
+ when GraphWeaver::Testing::Router then "the router"
220
+ when GraphWeaver::InProcess then "#{client.schema.name} (in-process)"
221
+ else client.schema.name ? "#{client.schema.name} (fake)" : "a fake"
222
+ end
223
+ end
224
+
225
+ # Live schema classes this process has loaded that nothing pointed
226
+ # :wire at. Named ones only — a dump loads as an anonymous subclass,
227
+ # and graphql-ruby's own NullSchema is not the app's. A class some
228
+ # graph already runs is named, just not by this graph. Sorted, because
229
+ # Class#subclasses is in no order and a log line should be the same
230
+ # line twice.
231
+ def self.unnamed_schemas(graph)
232
+ claimed = GraphWeaver.graphs.filter_map(&:live_schema)
233
+ loaded_schemas
234
+ .reject { |schema| claimed.include?(schema) || schema.equal?(graph&.live_schema) }
235
+ .sort_by(&:name)
236
+ end
237
+
238
+ # Class#subclasses is direct descendants only, so an app with its own
239
+ # base schema class needs the walk.
240
+ def self.loaded_schemas(root = GraphQL::Schema)
241
+ root.subclasses.flat_map do |schema|
242
+ named = schema.name && !schema.name.start_with?("GraphQL::") ? [schema] : []
243
+ named + loaded_schemas(schema)
244
+ end
245
+ end
246
+
247
+ # Take one stub back down. Only ours — a suite's other stubs, and
248
+ # whether it allows net connections, are its own business.
249
+ #
250
+ # Deleted rather than removed: the suite may have taken it down
251
+ # already (a group's own `after { WebMock.reset! }` runs first — rspec
252
+ # runs after hooks innermost-first), and remove_request_stub raises on
253
+ # a stub it can't find, piling a second failure on the example from
254
+ # inside the cleanup.
255
+ def self.unserve!(stub) = WebMock::StubRegistry.instance.request_stubs.delete(stub)
256
+
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.
262
+ def self.wire_targets
263
+ targets = GraphWeaver.graphs.filter_map do |graph|
264
+ client = baked_client(graph) || GraphWeaver.client
265
+ [endpoint!(client, graph), graph] if client
266
+ end
267
+ refuse_shared_endpoint!(targets)
268
+ return targets if targets.any?
269
+
270
+ # nothing bakes a client and the app has none: the endpoint refusal
271
+ # names the empty slot, which is the thing to fix
272
+ endpoint!(GraphWeaver.client)
273
+ end
274
+
275
+ # One stub per url, so two graphs on one endpoint used to mean the
276
+ # first graph's schema answering both — and the second's fields coming
277
+ # back as "doesn't exist on type 'Query'", which blames the query.
278
+ def self.refuse_shared_endpoint!(targets)
279
+ url, shared = targets.group_by(&:first).find { |_, at| at.size > 1 }
280
+ return unless shared
281
+
282
+ names = shared.map { |_, graph| graph.name.inspect }.join(", ")
283
+ raise GraphWeaver::Error, "#{TAG}: :wire serves one schema at each endpoint, and graphs " \
284
+ "#{names} post to the same one (#{url}) — whichever were served there would answer the " \
285
+ "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 " \
287
+ "#{TAG}: :router, which run above the wire."
288
+ end
289
+
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
+ # The endpoint a client posts to: a transport, a Retry around one, or a
305
+ # Client that built one. `graph` says whose client it is, when it isn't
306
+ # the app's own.
307
+ def self.endpoint!(client = GraphWeaver.client, graph = nil)
308
+ target = (client.transport if client.respond_to?(:transport)) || client
309
+ url = target.url if target.respond_to?(:url)
310
+ return url if url
311
+
312
+ raise GraphWeaver::Error, "#{TAG}: :wire runs your own transport against your resolvers, " \
313
+ "so it needs the endpoint that transport posts to — and #{whose_client(client, graph)}. " \
314
+ "There is nothing to serve. Point the client at a url " \
315
+ "(GraphWeaver.new(\"https://api.example.com/graphql\")), or tag the example " \
316
+ "#{TAG}: :in_process or #{TAG}: :router — they run above the wire."
317
+ end
318
+
319
+ # which client posts to nothing — the app's, or one graph's
320
+ def self.whose_client(client, graph)
321
+ return "GraphWeaver.client isn't set" unless client
322
+ return "GraphWeaver.client is #{client.class}, which posts to none" unless graph&.name
323
+
324
+ "graph #{graph.name.inspect} bakes client: #{client.class}, which posts to none"
325
+ end
326
+
327
+ def self.webmock!
328
+ unless defined?(WebMock)
329
+ raise GraphWeaver::Error, "#{TAG}: :wire serves your schema over HTTP, which needs " \
330
+ "webmock and rack — webmock hooks Net::HTTP, Faraday and HTTPX so your own transport " \
331
+ "runs unchanged, and its to_rack builds the Rack env with rack. Add both to the " \
332
+ "Gemfile (group :test) and `require \"webmock/rspec\"` in your spec helper."
333
+ end
334
+ unless webmock_enabled?
335
+ raise GraphWeaver::Error, "#{TAG}: :wire stubs your endpoints with webmock, which is " \
336
+ "loaded but not enabled — nothing is hooked, so this example's requests would leave " \
337
+ "the suite for the real endpoint. `require \"webmock/rspec\"` in your spec helper " \
338
+ "(Bundler.require only loads it), or WebMock.enable! for the suite."
339
+ end
340
+
341
+ require "rack" # WebMock's to_rack builds a Rack env but doesn't depend on rack
342
+ rescue LoadError
343
+ raise GraphWeaver::Error, "#{TAG}: :wire needs rack — webmock's to_rack builds a Rack " \
344
+ "env with it, but doesn't depend on it. Add it to the Gemfile (group :test)."
345
+ end
346
+
347
+ # WebMock has no "am I enabled" of its own, so the signal is the swap it
348
+ # makes: enable! puts its own subclass in Net::HTTP, disable! puts the
349
+ # original back. Requiring it only registers the adapters.
350
+ def self.webmock_enabled?
351
+ return true unless defined?(WebMock::HttpLibAdapters::NetHttpAdapter::OriginalNetHTTP)
352
+
353
+ !WebMock::HttpLibAdapters::NetHttpAdapter::OriginalNetHTTP.equal?(Net::HTTP)
354
+ end
355
+
356
+ private_class_method :wire_targets, :refuse_shared_endpoint!, :baked_client, :whose_client,
357
+ :webmock!, :webmock_enabled?, :disclose!, :served, :unnamed_schemas, :loaded_schemas
358
+
359
+ # Included into every example group, so graphql_context is there
360
+ # whether or not this example took a client from the hook.
361
+ #
362
+ # One helper per mode that has a per-example argument, named for that
363
+ # mode: graphql_<mode> is `graphql: <mode>` with something passed.
364
+ # :live and :wire have none, so they are the tag alone.
365
+ #
366
+ # **A helper called in an example is the stand-in for the modules of the
367
+ # graph `graph:` names — for this app's only graph when it names none —
368
+ # and it refuses, naming the graphs, when there is none it can reach.**
369
+ # So what an example says applies to what it then runs: a helper used to
370
+ # install itself at GraphWeaver.client, which each module's per-graph
371
+ # stand-in outranks, and a correct pin was silently dropped.
372
+ #
373
+ # **The tag sets the mode for every graph no helper named**, so one
374
+ # example runs two graphs in two modes — `graphql: :router` plus
375
+ # `graphql_fake(graph: :countries)` routes the federated graph and fakes
376
+ # the plain one. A helper reinstalled the example's one mode and cleared
377
+ # the table, so whichever graph was named last decided both.
378
+ #
379
+ # `graph:` takes the graph's name, the same handle `rake
380
+ # graph_weaver:graphs` prints and codegen bakes into a module. A schema
381
+ # object still names a graph too — `graphql_in_process(Reviews::Schema)`
382
+ # is the schema AND the graph in one word — but only a graph that runs
383
+ # that class in-process; one whose schema is a dump has no object to be
384
+ # matched by, and `graph:` is what reaches it.
141
385
  module Helpers
142
386
  # The fake this example runs against, built here rather than by the
143
387
  # tag — which is how it takes pins and options. `graphql: :fake` is
@@ -159,13 +403,23 @@ module GraphWeaver
159
403
  # 2.times { Dashboard.load }
160
404
  # expect(fake.requests.size).to eq 1
161
405
  #
162
- # Installed as GraphWeaver.client and restored after the example,
163
- # like a tagged oneso the tag is optional here, not required.
164
- def graphql_fake(pins = {}, **options)
165
- claim_mode!(:fake)
406
+ # With more than one graph, `graph:` says which one's modules this
407
+ # fake stands in forpins are schema-shaped, so there is no app-wide
408
+ # answer to guess at:
409
+ #
410
+ # graphql_fake(graph: :poke, "pokemon_v2_pokemon.name" => "pikachu")
411
+ #
412
+ # Installed for that graph and restored after the example, like a
413
+ # tagged one — so the tag is optional here, not required.
414
+ def graphql_fake(pins = {}, graph: nil, **options)
166
415
  refuse_seed!(options)
167
- options[:schema] ||= GraphWeaver::Testing.config.reference_schema!
168
- GraphWeaver.client = GraphWeaver::Testing::FakeClient.new(pins, **options)
416
+ graphs = targets!("graphql_fake", options[:schema],
417
+ "A fake fabricates that graph's shapes, with its scalar registrations.", graph:)
418
+ claim_mode!(:fake, graphs)
419
+ # the same two defaults the tag builds with (Internal::TestClients)
420
+ options[:schema] ||= GraphWeaver::Testing.config.reference_schema!(graphs.first)
421
+ options[:registry] ||= graphs.first&.registry
422
+ stand_in!(GraphWeaver::Testing::FakeClient.new(pins, **options), graphs)
169
423
  end
170
424
 
171
425
  # Run this example against one schema class's real resolvers.
@@ -184,11 +438,14 @@ module GraphWeaver
184
438
  #
185
439
  # Returns the client, and is restored after the example like a tagged
186
440
  # one — so the tag is optional here.
187
- def graphql_in_process(schema = nil, **options)
188
- claim_mode!(:in_process)
189
- schema ||= GraphWeaver::Testing.config.schema_class!
190
- options[:context] ||= GraphWeaver::Testing.config.context
191
- GraphWeaver.client = GraphWeaver::InProcess.new(schema, **options)
441
+ def graphql_in_process(schema = nil, graph: nil, **options)
442
+ graphs = targets!("graphql_in_process", schema,
443
+ "That graph's own schema class runs, and its resolvers stand in for its modules.",
444
+ graph:)
445
+ claim_mode!(:in_process, graphs)
446
+ schema ||= GraphWeaver::Testing.config.schema_class!(graphs.first)
447
+ options[:context] ||= GraphWeaver::Internal::TestClients.context
448
+ stand_in!(GraphWeaver::InProcess.new(schema, **options), graphs)
192
449
  end
193
450
 
194
451
  # Run this example against the whole federated graph. `graphql:
@@ -201,38 +458,81 @@ module GraphWeaver
201
458
  # …
202
459
  # end
203
460
  #
204
- # The router itself is built once for the suite — parsing a
205
- # supergraph per example is real time — so this installs that one and
206
- # tells it where this example starts.
207
- def graphql_router(fake: nil)
208
- claim_mode!(:router)
461
+ # A router is built once per supergraph — parsing one per example is
462
+ # real time — so this installs that one and tells it where this
463
+ # example starts. With more than one graph, `graph:` says whose
464
+ # supergraph the `fake:` is for; the tag alone already routes each
465
+ # module through its own.
466
+ def graphql_router(fake: nil, graph: nil)
209
467
  refuse_seed!(fake) if fake
210
- router = GraphWeaver::Testing::RSpecIntegration.client_for(:router)
468
+ graphs = targets!("graphql_router", nil,
469
+ "The tag alone already routes each module through its own graph's supergraph; name a " \
470
+ "graph only to say whose the fake: is for.", graph:)
471
+ claim_mode!(:router, graphs)
472
+ # :router explicitly: under a :wire tag the table would otherwise
473
+ # hand back whatever :wire picked for this graph
474
+ router = GraphWeaver::Internal::TestClients.standin(graphs.first, :router)
211
475
  router.fake = fake if fake
212
- GraphWeaver.client = router
476
+ stand_in!(router, graphs)
213
477
  end
214
478
 
215
- # A tag and a helper are two spellings of one choice, so they can
216
- # agree (`graphql: :fake` plus `graphql_fake(overrides:)` is the
217
- # documented way to pass options) but must not contradict: one of the
218
- # two is then a mistake, and silently letting the later one win hides
219
- # which.
220
- private def claim_mode!(mode)
479
+ # A tag and a helper are two spellings of one choice WHEN the helper
480
+ # speaks for the whole example which, in an app with one graph, it
481
+ # always does. They can then agree (`graphql: :fake` plus
482
+ # `graphql_fake(overrides:)` is the documented way to pass options)
483
+ # but must not contradict: one of the two is a mistake, and silently
484
+ # letting the later one win hides which.
485
+ #
486
+ # `graphs` is what this helper stands in for, so a helper naming one
487
+ # graph of several isn't contradicting anything — the tag is still
488
+ # the example's mode for every graph it leaves alone.
489
+ #
490
+ # :wire is the exception because it is not the same question — it says
491
+ # a stand-in is served rather than substituted, and the helper says
492
+ # which stand-in.
493
+ private def claim_mode!(mode, graphs)
494
+ # :wire says WHERE a stand-in runs — served at the endpoint the
495
+ # client posts to — not which one it is, so a helper under it names
496
+ # what goes behind the wire and the example stays :wire
497
+ return if wire?
498
+ return unless graphs.size == GraphWeaver.graphs.size
499
+
221
500
  # only an explicit tag can contradict a helper. config.default_mode
222
501
  # is a fallback for examples that said nothing, so a helper is the
223
502
  # example finally saying something — not a disagreement.
224
503
  tagged = defined?(@__graph_weaver_tag) ? @__graph_weaver_tag : nil
225
- if tagged && tagged != mode
226
- # Kernel.raise: this module is mixed into every example group, so
227
- # it doesn't include Kernel for sorbet to find
228
- Kernel.raise GraphWeaver::Error, "this example is tagged #{TAG}: #{tagged.inspect} but calls " \
229
- "graphql_#{mode} drop one. The tag is the helper with no arguments, so keep the " \
230
- "helper when you need to pass it something."
231
- end
504
+ return unless tagged && tagged != mode
505
+
506
+ # Kernel.raise: this module is mixed into every example group, so
507
+ # it doesn't include Kernel for sorbet to find
508
+ Kernel.raise GraphWeaver::Error, "this example is tagged #{TAG}: #{tagged.inspect} but calls " \
509
+ "graphql_#{mode} — drop one. A tag and a helper are two spellings of one choice, so " \
510
+ "keep the helper when you need to pass it something."
511
+ end
232
512
 
233
- @__graph_weaver_mode = mode
513
+ # The graphs this helper's client stands in for — see the rule above.
514
+ private def targets!(helper, schema, advice, graph: nil)
515
+ GraphWeaver::Internal::TestClients.targets!(helper, schema, advice, graph:)
234
516
  end
235
517
 
518
+ # Put `client` in the slot those graphs' modules read. An app with one
519
+ # graph has one answer, so the app slot holds it too — which is what
520
+ # GraphWeaver.client reads back as, and what makes this helper's
521
+ # return value the object the modules actually run against.
522
+ private def stand_in!(client, graphs)
523
+ GraphWeaver::Internal::TestClients.override!(client, graphs)
524
+ # except under :wire, where this is served at the graph's endpoint
525
+ # instead — the app's own client has to stay in the slot for the
526
+ # transport under test to run at all
527
+ GraphWeaver.client = client if GraphWeaver.graphs.one? && !wire?
528
+ client
529
+ end
530
+
531
+ # Whether this example serves its stand-ins rather than substituting
532
+ # them. Read off the mode, not the tag, so config.default_mode = :wire
533
+ # behaves the same way.
534
+ private def wire? = defined?(@__graph_weaver_mode) && @__graph_weaver_mode == :wire
535
+
236
536
  # rspec's own --seed already drives the fake (config.seed takes it
237
537
  # at suite start), so a per-example seed: is a second answer to one
238
538
  # question — and the one that stops `rspec --seed` reproducing the run.
@@ -256,13 +556,13 @@ module GraphWeaver
256
556
  return baseline unless values
257
557
 
258
558
  merged = baseline.merge(values)
259
- GraphWeaver::Testing::RSpecIntegration.set_context(mode, merged)
559
+ GraphWeaver::Internal::TestClients.context = merged
260
560
  return merged unless block
261
561
 
262
562
  begin
263
563
  block.call
264
564
  ensure
265
- GraphWeaver::Testing::RSpecIntegration.set_context(mode, baseline)
565
+ GraphWeaver::Internal::TestClients.context = baseline
266
566
  end
267
567
  end
268
568
  end
@@ -271,7 +571,12 @@ module GraphWeaver
271
571
  # one would leave an example asserting on data nothing scoped.
272
572
  def self.context!(mode)
273
573
  case mode
274
- when :in_process, :router then GraphWeaver.client.context
574
+ when :in_process, :router
575
+ # a context: proc is answered from the request's headers, so
576
+ # there is no baseline here to merge onto
577
+ Internal::Util.context!(Internal::TestClients.context)
578
+ when :wire
579
+ wire_context!
275
580
  when :fake
276
581
  raise GraphWeaver::Error, "graphql_context needs resolvers to receive it, and a " \
277
582
  "#{TAG}: :fake example runs against fabricated data — tag it #{TAG}: :in_process or " \
@@ -283,16 +588,22 @@ module GraphWeaver
283
588
  end
284
589
  end
285
590
 
286
- def self.set_context(mode, values)
287
- client = GraphWeaver.client
288
- # the router is built once for the suite, so it takes a new context
289
- # rather than being rebuilt; an InProcess is two ivars, so it isn't
290
- # worth making mutable for this
291
- case mode
292
- when :router then client.context = values
293
- when :in_process then GraphWeaver.client = GraphWeaver::InProcess.new(client.schema, context: values)
294
- end
591
+ # :wire is the one mode that IS a request, so a context: proc is
592
+ # answered — by the headers the example's own transport sends. The
593
+ # generic refusal says to tag the example :wire, which this one already
594
+ # is; what to change here is the header.
595
+ def self.wire_context!
596
+ context = Internal::TestClients.context
597
+ return context unless context.respond_to?(:call)
598
+
599
+ raise GraphWeaver::Error, "context: is a proc, so it is answered from the headers of each " \
600
+ "request — which is what #{TAG}: :wire makes, and why graphql_context has nothing here " \
601
+ "to read or merge onto. Say who this example is where the headers are, on the client's " \
602
+ "own transport: GraphWeaver.new(url, headers: { \"X-User\" => \"2\" }) — a header value " \
603
+ "may itself be a proc, so it can vary per request."
295
604
  end
605
+ private_class_method :wire_context!
606
+
296
607
  end
297
608
  end
298
609
  end