graph_weaver 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1447 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +20 -6
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +497 -104
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +28 -0
  15. data/docs/scalars.md +190 -26
  16. data/docs/testing.md +457 -58
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +328 -3
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +404 -197
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +40 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +474 -106
  70. metadata +56 -1
@@ -50,6 +50,20 @@ module GraphWeaver
50
50
  "ancestor, and only the gateway that planned the fetch knows what to put there. Run " \
51
51
  "this one against a real router.",
52
52
  ],
53
+ incremental_delivery: [
54
+ "a response delivered in more than one payload",
55
+ "@defer/@stream stream the rest of the answer over a multipart body after the first " \
56
+ "payload, and this router answers in one. Run this one against a real router — the " \
57
+ "Apollo Router supports @defer behind an Accept header; @apollo/gateway doesn't " \
58
+ "know the directive at all.",
59
+ ],
60
+ progressive_override: [
61
+ "a progressive @override still rolling out",
62
+ "federation 2.7's @override(label:) leaves both subgraphs resolving the field and " \
63
+ "splits traffic between them by a rule only the gateway evaluates — a local router " \
64
+ "would pick one and answer from it every time. Run this one against a real router, " \
65
+ "or finish the rollout (drop the label) first.",
66
+ ],
53
67
  chained_requires: [
54
68
  "a @requires whose field set names another @requires field",
55
69
  "the router satisfies a @requires with one fetch, so it can't first satisfy that " \
@@ -214,8 +228,11 @@ module GraphWeaver
214
228
  # subgraphs answered with fabricated data instead of that refusal
215
229
  attr_reader :faked
216
230
 
217
- # the context handed to every subgraph — settable, so one example can
218
- # run as a different user without rebuilding the router
231
+ # The context handed to every subgraph — settable, so one example can
232
+ # run as a different user without rebuilding the router. A proc is
233
+ # answered from the request's headers, which only a wire supplies:
234
+ # `context: ->(headers) { { current_user: User.find_by(token:
235
+ # headers["Authorization"]) } }` served through {Endpoint}.
219
236
  attr_accessor :context
220
237
 
221
238
  # The planner injects key fields under this prefix, and the concrete
@@ -292,6 +309,12 @@ module GraphWeaver
292
309
  return { "data" => nil, "errors" => [Internal::Wire.graphql_error(e.message, "GRAPHQL_PARSE_FAILED")] }
293
310
  end
294
311
 
312
+ # above validation, which would otherwise answer "Directive @defer is
313
+ # not defined" — an accident of the composed schema, not this router's
314
+ # decision, and one that stops refusing the day a supergraph @links
315
+ # the defer spec
316
+ @planner.refuse_incremental!(document)
317
+
295
318
  # validate the way a router does, so a stale query fails as it fails
296
319
  # in production rather than somewhere inside the planner
297
320
  errors = @planner.validate(document)
@@ -326,6 +349,12 @@ module GraphWeaver
326
349
  # green — the same silent pass a typo'd override key is refused for.
327
350
  def check_fake!(options)
328
351
  options = options.to_h
352
+ # rspec's --seed already drives the fake, and a router is built once
353
+ # for the suite — a seed here would pin every example to one run
354
+ if options.key?(:seed) || options.key?("seed")
355
+ raise GraphWeaver::ConfigurationError, "seed: isn't a fake: option — `rspec --seed 1234` " \
356
+ "reproduces a run, and GraphWeaver::Testing.config.seed sets one for a harness that isn't rspec"
357
+ end
329
358
  return options.freeze if options.empty? || @faked.any?
330
359
 
331
360
  raise GraphWeaver::ConfigurationError, "fake: says how faked subgraphs fabricate, and this " \
@@ -352,7 +381,7 @@ module GraphWeaver
352
381
 
353
382
  plan.steps.each do |step|
354
383
  result = fetch_step(step, plan.operation, given)
355
- Array(result["errors"]).each { |error| errors << rewrite(error) }
384
+ Array(result["errors"]).each { |error| errors << rewrite(error, step.subgraph) }
356
385
  payload = result["data"]
357
386
  if payload.nil?
358
387
  # the subgraph nulled its whole response, so every field it was
@@ -433,10 +462,12 @@ module GraphWeaver
433
462
  # plan is built once and reused, so only here are the variables known.
434
463
  wanted = step.deferrals.select { |d| included?(d.node, variables) }
435
464
 
436
- # a @requires fetch and a plain one need different node sets, so they
437
- # can't share a call even into the same subgraph which is the split
438
- # a real router makes too
439
- wanted.group_by { |d| [d.subgraph, d.requires.any?] }.each do |(target, chained), deferrals|
465
+ # Everything crossing into one subgraph from this level rides one
466
+ # call, @requires and plain together, as Apollo's does. The exception
467
+ # is a prefetch that didn't answer for some node: the @requires half
468
+ # then runs over fewer nodes than the plain half, and two node sets
469
+ # can't share a fetch.
470
+ wanted.group_by { |d| [d.subgraph, d.requires.any? && blocked.any?] }.each do |(target, chained), deferrals|
440
471
  fetched = chained ? nodes.reject { |(node, _)| blocked.include?(node.object_id) } : nodes
441
472
  tree = Internal::Planner.field_tree(deferrals.flat_map(&:representation).uniq)
442
473
  representations = fetched.map { |(node, _)| representation(node, tree, step.type_name) }
@@ -445,7 +476,7 @@ module GraphWeaver
445
476
  if fetched.any?
446
477
  result = entities_fetch(target, step.type_name, deferrals.map(&:node), representations, operation, variables)
447
478
  entities = result.dig("data", "_entities") || []
448
- Array(result["errors"]).each { |error| errors << rewrite(error, fetched) }
479
+ Array(result["errors"]).each { |error| errors << rewrite(error, target, fetched) }
449
480
  end
450
481
 
451
482
  fetched.each_with_index do |(node, _), index|
@@ -500,7 +531,7 @@ module GraphWeaver
500
531
 
501
532
  result = entities_fetch(subgraph, step.type_name, selections, representations, operation, variables)
502
533
  entities = result.dig("data", "_entities") || []
503
- Array(result["errors"]).each { |error| errors << rewrite(error, nodes) }
534
+ Array(result["errors"]).each { |error| errors << rewrite(error, subgraph, nodes) }
504
535
 
505
536
  nodes.each_with_index do |(node, _), index|
506
537
  entity = entities[index]
@@ -556,7 +587,8 @@ module GraphWeaver
556
587
  # is a path into the fetch, and `locations` a position in it. Re-path
557
588
  # what can be re-pathed and drop what can't, rather than hand back a
558
589
  # line number pointing into a document that doesn't exist.
559
- def rewrite(error, nodes = nil)
590
+ def rewrite(error, subgraph, nodes = nil)
591
+ error = stamp(error, subgraph)
560
592
  path = error["path"]
561
593
  return error.except("locations") unless path.is_a?(Array)
562
594
 
@@ -565,6 +597,20 @@ module GraphWeaver
565
597
  error.except("locations").merge("path" => prefix + unalias(stitched ? path[2..] : path))
566
598
  end
567
599
 
600
+ # Which subgraph a bubbled error came from. Every real transport stamps
601
+ # this and a client branches on it to tell a downstream failure from an
602
+ # ordinary business error, so a test written against an unstamped one
603
+ # passes here and breaks in front of a gateway. Apollo Router's spelling
604
+ # (`extensions.service`); the deprecated JS gateway says `serviceName`
605
+ # and adds a DOWNSTREAM_SERVICE_ERROR code, which isn't ours to invent.
606
+ # Whatever the subgraph's own resolver set is left alone.
607
+ def stamp(error, subgraph)
608
+ extensions = error["extensions"].is_a?(Hash) ? error["extensions"] : {}
609
+ return error if extensions.key?("service") || extensions.key?("serviceName")
610
+
611
+ error.merge("extensions" => extensions.merge("service" => subgraph))
612
+ end
613
+
568
614
  # The @key/@requires fields we inject are ours; an error path naming one
569
615
  # points the caller at a field no schema has.
570
616
  def unalias(path)
@@ -657,7 +703,8 @@ module GraphWeaver
657
703
  end
658
704
 
659
705
  GraphWeaver::Internal::Log.log_timed(:debug, "router -> #{name} #{tag} completed") do
660
- @subgraphs.fetch(name).execute(query, variables:, operation_name:, context: @context).to_h
706
+ @subgraphs.fetch(name).execute(query, variables:, operation_name:,
707
+ context: Internal::Util.context!(@context)).to_h
661
708
  end
662
709
  end
663
710
 
@@ -22,7 +22,8 @@ end
22
22
  # # when it isn't the dump, or
23
23
  # # fake: for how those fabricate
24
24
  # config.context = { current_user: } # baseline GraphQL context
25
- # config.default_mode = :fake # untagged examples (graph_weaver/rspec)
25
+ # config.default_mode = :fake # untagged examples; :live (the
26
+ # # default) leaves your client alone
26
27
  # config.seed = 42 # reproducible fakes
27
28
  # config.overrides = { "Person.name" => "Daniel" }
28
29
  # config.list_size = 2..4
@@ -49,33 +50,43 @@ module GraphWeaver
49
50
  # What an example can run against, named by the rspec tag that selects
50
51
  # it — `it "…", graphql: :in_process` (see graph_weaver/rspec):
51
52
  #
53
+ # :live your app's own client, exactly as it is — the
54
+ # default, and how one example steps back out of
55
+ # config.default_mode
52
56
  # :fake fabricated, schema-correct data; no resolvers run
53
57
  # :in_process your resolvers, one live schema class, in-process
54
58
  # :router your resolvers, across a federated graph
55
- CLIENT_MODES = %i[fake in_process router].freeze
59
+ # :wire your schema, served at your client's endpoint so
60
+ # your real transport runs
61
+ CLIENT_MODES = %i[live fake in_process router wire].freeze
56
62
 
57
63
  class Config
58
- attr_accessor :overrides, :seed, :list_size, :cassette_dir, :context,
59
- :record, :anonymize
60
- # #schema is written plainly and read with a fallback (below), the way
61
- # #router and #default_mode are read plainly and written with a check
62
- attr_writer :schema
64
+ # How long an unbounded list fabricates when nothing names it — the
65
+ # starting #list_size, and the fallback under a Hash one with no
66
+ # `default:`.
67
+ DEFAULT_LIST_SIZE = (1..3).freeze
68
+
69
+ attr_accessor :overrides, :seed, :list_size, :cassette_dir, :record, :anonymize
70
+ attr_reader :context
71
+ # #schema is read with a fallback (below), the way #router and
72
+ # #default_mode are read plainly and written with a check
63
73
  attr_reader :router, :default_mode
64
74
 
65
75
  def initialize
66
76
  @overrides = {}
67
77
  @seed = nil
68
- @list_size = 1..3
78
+ @list_size = DEFAULT_LIST_SIZE
69
79
  @schema = nil
70
80
  @located = nil # the committed dump, once located
81
+ @located_path = nil # and the path it was located at
71
82
  # not under spec/fixtures: `fixtures :all` globs that path for
72
83
  # `{**,*}/*.yml` and would try to load cassettes as ActiveRecord
73
84
  # fixtures, a subdirectory included
74
85
  @cassette_dir = "spec/cassettes"
75
- # what an example with no `graphql:` tag runs against. nil leaves
86
+ # what an example with no `graphql:` tag runs against. :live leaves
76
87
  # GraphWeaver.client alone: swapping every example onto something
77
88
  # else is too surprising to be a default.
78
- @default_mode = nil
89
+ @default_mode = :live
79
90
  # the GraphQL context every :in_process / :router example starts
80
91
  # from; graphql_context merges onto it
81
92
  @context = {}
@@ -95,7 +106,26 @@ module GraphWeaver
95
106
  # the dump memoizes separately: explicit_schema has to stay honest
96
107
  # about whether anyone set one, since :in_process won't run a dump's
97
108
  # resolver-less types as if they were the live class
98
- @schema || (@located ||= GraphWeaver::SchemaLoader.locate)
109
+ return @schema if @schema
110
+
111
+ # keyed on the path it came from, so schema_path= and root= aren't
112
+ # invisible — a memo that outlived them faked the previous schema's
113
+ # shapes with nothing said. Worth keeping: loading a real
114
+ # introspection dump is ~100ms and every fake asks.
115
+ path = GraphWeaver::SchemaLoader.locate_path
116
+ return unless path
117
+
118
+ @located = GraphWeaver::SchemaLoader.load(path) unless @located_path == path
119
+ @located_path = path
120
+ @located
121
+ end
122
+
123
+ # What every mode derives from — suite setup, like #context and for the
124
+ # same reason: an example's clients are built before any group `before`
125
+ # runs, so one set there is read too late (see {refuse_late!}).
126
+ def schema=(schema)
127
+ refuse_late!("config.schema", "graphql_in_process(MySchema) / graphql_fake(schema: MySchema)")
128
+ @schema = schema
99
129
  end
100
130
 
101
131
  # What's been set, without falling back to the dump — so validating
@@ -103,11 +133,28 @@ module GraphWeaver
103
133
  # that never asks for one.
104
134
  def explicit_schema = @schema
105
135
 
136
+ # The baseline every example starts from — suite setup, not something an
137
+ # example changes out from under itself: an example's clients are built
138
+ # before any group hook runs (a :wire example's, before its endpoints
139
+ # are stubbed), so a `before { config.context = … }` used to be read too
140
+ # late and silently never reach a resolver.
141
+ def context=(values)
142
+ if GraphWeaver::Internal::TestClients.installed?
143
+ raise GraphWeaver::Error, "config.context is the baseline every example starts from, " \
144
+ "read when that example's clients are built — so setting it from inside an example " \
145
+ "would never reach a resolver. Say it for this example with " \
146
+ "graphql_context(current_user: …), or for the suite in GraphWeaver::Testing.configure " \
147
+ "(an around hook works too — it wraps the setup a tag does)."
148
+ end
149
+
150
+ @context = values
151
+ end
152
+
106
153
  def default_mode=(mode)
107
- unless mode.nil? || CLIENT_MODES.include?(mode)
154
+ unless CLIENT_MODES.include?(mode)
108
155
  raise ArgumentError,
109
- "default_mode: must be one of #{CLIENT_MODES.inspect} (or nil to leave " \
110
- "GraphWeaver.client alone), got #{mode.inspect}"
156
+ "default_mode: must be one of #{CLIENT_MODES.inspect}, got #{mode.inspect} " \
157
+ ":live leaves GraphWeaver.client exactly as it is, and is the default"
111
158
  end
112
159
 
113
160
  @default_mode = mode
@@ -122,6 +169,7 @@ module GraphWeaver
122
169
  # have to restate where the supergraph is. `fake:` says how those
123
170
  # fabricate; graphql_router(fake: …) says it for one example.
124
171
  def router=(arguments)
172
+ refuse_late!("config.router", "graphql_router(fake: …)")
125
173
  unless arguments.nil? || arguments.is_a?(Hash)
126
174
  raise ArgumentError, "router: must be the arguments to build one, e.g. " \
127
175
  "{ supergraph: \"supergraph.graphql\" } or { subgraphs: { \"reviews\" => :fake } }, " \
@@ -137,56 +185,113 @@ module GraphWeaver
137
185
  raise ArgumentError, "router: doesn't take #{unknown.join(", ")}" if unknown.any?
138
186
 
139
187
  @router = arguments
140
- @built_router = nil
188
+ @built_routers = nil
141
189
  end
142
190
 
143
- # Built once: parsing the supergraph is setup, not per-example work.
144
- # #context is settable, so an example that runs as someone else sets
145
- # that rather than rebuilding the rspec hook resets it each time.
146
- def built_router
147
- @built_router ||= Router.new(
148
- supergraph: supergraph!,
191
+ # The router `graph` plans against, built once per supergraph: parsing
192
+ # one is setup, not per-example work, and two graphs naming the same
193
+ # supergraph are one plan. #context is settable, so an example that runs
194
+ # as someone else sets that rather than rebuilding.
195
+ def built_router(graph = nil)
196
+ source = supergraph!(graph)
197
+ @built_routers ||= {}
198
+ @built_routers[source] ||= Router.new(
199
+ supergraph: source,
149
200
  subgraphs: @router && @router[:subgraphs],
150
201
  fake: (@router && @router[:fake]) || {},
151
202
  )
152
203
  end
153
204
 
154
- # The composed supergraph :router plans against — named, or the
155
- # conventional dump when that's what it is. A client can't supply
156
- # one: its schema is the API schema a router serves, with the
205
+ # Whether `graph` has a composed supergraph to plan against — what
206
+ # decides whether :wire serves the router or the live schema class,
207
+ # the same question :router and :in_process each answer for themselves.
208
+ def supergraph?(graph = nil) = !supergraph_for(graph).nil?
209
+
210
+ # The composed supergraph `graph` plans against, or the refusal saying
211
+ # what was looked for. Per graph, because a graph that is in no
212
+ # supergraph must be refused by name rather than routed into someone
213
+ # else's.
214
+ private def supergraph!(graph = nil)
215
+ supergraph_for(graph) ||
216
+ raise(GraphWeaver::Error, supergraph_advice(graph, GraphWeaver::SchemaLoader.locate_path))
217
+ end
218
+
219
+ # The lookup on its own, nil when there is none — so asking the
220
+ # question doesn't build an error. Every GraphWeaver::Error writes a
221
+ # warn line as it is constructed, and a predicate that raised to say
222
+ # "no" put a refusal that never happened in the log of every :wire
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
157
226
  # @join__* routing table stripped out.
158
- private def supergraph!
227
+ private def supergraph_for(graph)
228
+ # named_schema?, so a graph that declared no schema of its own falls
229
+ # through to config.router rather than past it to the conventional dump
230
+ named = (graph.supergraph if graph&.named_schema?)
231
+ return named if named
159
232
  return @router[:supergraph] if @router&.key?(:supergraph)
160
233
 
161
234
  path = GraphWeaver::SchemaLoader.locate_path
162
- return path if path && supergraph?(path)
235
+ path if path && GraphWeaver::Internal::Util.composed?(path)
236
+ end
163
237
 
164
- raise GraphWeaver::Error, ":router needs the composed supergraph SDL a client's schema " \
165
- "is the API schema the router serves, with the @join__* routing table stripped out, so " \
166
- "the supergraph has to be named. #{path ? "#{path} carries no @join__* markers" : "Nothing on disk at #{GraphWeaver.schema_path}"}. " \
167
- "Set GraphWeaver::Testing.config.router = { supergraph: \"supergraph.graphql\" }."
238
+ # what to do about it, which differs by who asked: a graph in no
239
+ # supergraph is one schema, so the tag for one schema is the answer;
240
+ # the app-wide ask is told the two app-wide places to name one
241
+ private def supergraph_advice(graph, path)
242
+ if graph&.name
243
+ ":router plans a query across a composed supergraph, and graph #{graph.name.inspect} " \
244
+ "is in none — tag the example graphql: :in_process, which runs one schema class's " \
245
+ "resolvers, or name the supergraph where the graph is declared: " \
246
+ "GraphWeaver.graph(#{graph.name.inspect}) { schema \"supergraph.graphql\" }."
247
+ else
248
+ ":router needs the composed supergraph SDL — a client's schema is the API schema the " \
249
+ "router serves, with the @join__* routing table stripped out, so the supergraph has " \
250
+ "to be named. #{path ? "#{path} carries no @join__* markers" : "Nothing on disk at #{GraphWeaver.schema_path}"}. " \
251
+ "Set GraphWeaver::Testing.config.router = { supergraph: \"supergraph.graphql\" }, or " \
252
+ "name it where the graph is declared: GraphWeaver.graph(:api) { schema \"supergraph.graphql\" }."
253
+ end
168
254
  end
169
255
 
170
256
  # The live schema class :in_process runs when the example didn't name
171
- # one — config.schema if that is a class, else whatever the app's own
172
- # client already runs in-process. Only a live class has resolvers, so
173
- # there is nothing else to fall back to: a dump is type information.
174
- def schema_class!
175
- # explicit_schema, not schema: the latter falls back to the committed
176
- # dump, which loads as an anonymous GraphQL::Schema subclass runnable
177
- # by every test that matters, and holding not one resolver.
178
- runnable(explicit_schema) || GraphWeaver::Internal::Util.live_schema ||
179
- raise(GraphWeaver::Error, ":in_process runs your resolvers, so it needs the live " \
180
- "GraphQL::Schema class — and GraphWeaver.client isn't running one in-process to " \
181
- "borrow. Name it in the example — graphql_in_process(MySchema) — or set " \
182
- "GraphWeaver::Testing.config.schema = MySchema for the whole suite. A federated app " \
183
- "names the subgraph it means, per example; graphql: :router runs the graph stitched.")
184
- end
185
-
186
- # The schema everything else derives from: the one you set, else the
187
- # committed dump, else the schema the app's client talks to.
188
- def reference_schema!
189
- found = schema || (GraphWeaver.client.schema if GraphWeaver.client.respond_to?(:schema))
257
+ # one — config.schema if that is a class, else the one `graph` names,
258
+ # else whatever the app's own client already runs in-process. Only a
259
+ # live class has resolvers, so there is nothing else to fall back to:
260
+ # a dump is type information.
261
+ def schema_class!(graph = nil)
262
+ # a named graph is told how to name its own class; the app-wide answer
263
+ # is told the two app-wide ways to say it
264
+ schema_class_for(graph) || raise(GraphWeaver::Error, ":in_process runs your resolvers, " \
265
+ "so it needs the live GraphQL::Schema class — and #{schema_class_advice(graph)}")
266
+ end
267
+
268
+ # Whether `graph` has a live schema class at all what decides, with
269
+ # #supergraph?, which of the three things :wire serves. Asked without
270
+ # building an error, for the reason {supergraph_for} gives.
271
+ def schema_class?(graph = nil) = !schema_class_for(graph).nil?
272
+
273
+ # The schema everything else derives from: the one you set, else the one
274
+ # `graph` names — the schema its generated code was checked against —
275
+ # else the one this app's single graph names, else the committed dump,
276
+ # else the schema the app's client talks to.
277
+ def reference_schema!(graph = nil)
278
+ return explicit_schema if explicit_schema
279
+ return graph.schema if graph&.named_schema?
280
+
281
+ declared = GraphWeaver.graphs
282
+ # more than one graph and nothing named: the honest answer varies per
283
+ # example, and picking the first would fake one schema's shapes at
284
+ # another's module — a wrong answer that looks authoritative
285
+ if declared.size > 1
286
+ raise GraphWeaver::Error, "this app has #{declared.size} graphs " \
287
+ "(#{declared.map { |graph| graph.name.inspect }.join(", ")}), so which schema to fake " \
288
+ "against varies per example — name it: graphql_fake(schema: MySchema) or " \
289
+ "graphql_in_process(MySchema). Set GraphWeaver::Testing.config.schema only if the whole " \
290
+ "suite means one of them."
291
+ end
292
+
293
+ found = (declared.first.schema if declared.first.named_schema?)
294
+ found ||= schema || (GraphWeaver.client.schema if GraphWeaver.client.respond_to?(:schema))
190
295
  return found if found
191
296
 
192
297
  raise GraphWeaver::Error, "no schema to run against — GraphWeaver.client isn't set, " \
@@ -196,18 +301,50 @@ module GraphWeaver
196
301
 
197
302
  private
198
303
 
304
+ # explicit_schema, not schema: the latter falls back to the committed
305
+ # dump, which loads as an anonymous GraphQL::Schema subclass — runnable
306
+ # by every test that matters, and holding not one resolver.
307
+ def schema_class_for(graph)
308
+ runnable(explicit_schema) || graph&.live_schema || GraphWeaver::Internal::Util.live_schema
309
+ end
310
+
311
+ # One rule for every suite-setup setting: say it at load, or in an
312
+ # `around` — a plain `before` is too late, because the tag builds (and
313
+ # under :wire serves) this example's clients in a `before` of its own,
314
+ # and rspec runs that one first. Silence there is the expensive
315
+ # outcome: the example passes against whatever the tag already picked.
316
+ def refuse_late!(setting, per_example)
317
+ return unless GraphWeaver::Internal::TestClients.built?
318
+
319
+ raise GraphWeaver::Error, "#{setting} is read when this example's clients are built, and " \
320
+ "the graphql: tag already built them — it does that in a `before` hook of its own, which " \
321
+ "rspec runs before yours, so setting it now reaches nothing. Say it for the suite in " \
322
+ "GraphWeaver::Testing.configure, or in an `around` hook, which wraps the tag's setup; " \
323
+ "say it for one example in the helper (#{per_example})."
324
+ end
325
+
326
+ # what to do about it, which differs by who asked: a graph names its
327
+ # own class where it is declared, the app names one for the suite
328
+ def schema_class_advice(graph)
329
+ if graph&.name
330
+ "graph #{graph.name.inspect} names " \
331
+ "#{graph.named_schema? ? "type information, not a class" : "no schema of its own"}. " \
332
+ "Declare it with the class: GraphWeaver.graph(#{graph.name.inspect}) " \
333
+ "{ schema -> { MySchema } }."
334
+ else
335
+ "GraphWeaver.client isn't running one in-process to borrow. Name it in the example — " \
336
+ "graphql_in_process(MySchema) — or set GraphWeaver::Testing.config.schema = MySchema " \
337
+ "for the whole suite. A federated app names the subgraph it means, per example; " \
338
+ "graphql: :router runs the graph stitched."
339
+ end
340
+ end
341
+
199
342
  # config.schema doubles as the :in_process class when it is one — but a
200
343
  # dump has no resolvers, so it can only ever be type information.
201
344
  def runnable(schema)
202
345
  schema if schema.is_a?(Class) && schema <= GraphQL::Schema
203
346
  end
204
347
 
205
- def supergraph?(source)
206
- GraphWeaver::SchemaLoader.routing_table(source)
207
- true
208
- rescue GraphWeaver::Error
209
- false
210
- end
211
348
  end
212
349
 
213
350
  class << self
@@ -217,9 +354,13 @@ module GraphWeaver
217
354
 
218
355
  def configure
219
356
  yield config
220
- # a typo'd override key pins nothing and the test still passes, so
221
- # catch it here — while the block that set it is still on the stack
222
- Internal::Overrides.validate!(config.explicit_schema, config.overrides) if config.explicit_schema
357
+ # a typo'd override or list_size key names nothing and the test still
358
+ # passes, so catch it here — while the block that set it is still on
359
+ # the stack
360
+ if (schema = config.explicit_schema)
361
+ Internal::Overrides.validate!(schema, config.overrides)
362
+ Internal::Overrides.validate_list_size!(schema, config.list_size)
363
+ end
223
364
  config
224
365
  end
225
366
 
@@ -249,5 +390,6 @@ require_relative "testing/fake_client"
249
390
  require_relative "testing/fake_subgraph"
250
391
  require_relative "testing/failure"
251
392
  require_relative "testing/cassette"
393
+ require_relative "testing/endpoint"
252
394
  require_relative "testing/router"
253
395
  require_relative "testing/coverage"
@@ -7,9 +7,9 @@ require_relative "../transport"
7
7
 
8
8
  module GraphWeaver
9
9
  class Transport
10
- # Faraday-backed transport. Opt-in (faraday is not a hard dependency):
11
- #
12
- # require "graph_weaver/transport/faraday"
10
+ # Faraday-backed transport. Opt-in faraday is not a hard dependency, and
11
+ # naming this constant is what loads it (Transport autoloads this file), so
12
+ # an app that never mentions it never pays for it:
13
13
  #
14
14
  # # simplest: build a default connection from a url
15
15
  # GraphWeaver::Transport::Faraday.new("https://api.example.com/graphql")
@@ -30,6 +30,7 @@ module GraphWeaver
30
30
  )
31
31
 
32
32
  def initialize(url_or_connection, headers: {}, open_timeout: nil, read_timeout: nil, &block)
33
+ @dynamic = {} # a prebuilt connection owns its headers; nothing is held back
33
34
  @connection = case url_or_connection
34
35
  when ::Faraday::Connection
35
36
  # a prebuilt connection carries its own headers/middleware/
@@ -41,6 +42,13 @@ module GraphWeaver
41
42
 
42
43
  url_or_connection
43
44
  else
45
+ # Faraday sets connection headers once, stringifying as it goes, so
46
+ # a callable there would ship as "#<Proc:0x…>". Held back and
47
+ # resolved per request in #post instead — a rotating token has to
48
+ # mean the same thing on both bundled transports.
49
+ @dynamic = headers.select { |_, value| value.respond_to?(:call) }
50
+ headers = headers.reject { |name, _| @dynamic.key?(name) }
51
+
44
52
  # Faraday appends the default adapter when the block doesn't set
45
53
  # one. Our defaults go on the connection so ours is the
46
54
  # User-Agent, not Faraday's stock one; caller headers still win.
@@ -48,7 +56,10 @@ module GraphWeaver
48
56
  # otherwise inherit net/http's 60s/60s.
49
57
  ::Faraday.new(
50
58
  url: url_or_connection,
51
- headers: DEFAULT_HEADERS.merge(headers),
59
+ # to_s for the same reason Transport::HTTP does it — net/http calls
60
+ # #strip on a header value, so an Integer id raised from inside the
61
+ # adapter, naming neither graph_weaver nor the header
62
+ headers: Transport.default_headers.merge(headers).transform_values(&:to_s),
52
63
  request: {
53
64
  open_timeout: open_timeout || DEFAULT_OPEN_TIMEOUT,
54
65
  read_timeout: read_timeout || DEFAULT_READ_TIMEOUT,
@@ -56,21 +67,43 @@ module GraphWeaver
56
67
  &block
57
68
  )
58
69
  end
59
- @url = @connection.url_prefix.to_s
70
+ @url = endpoint_url(@connection)
60
71
 
61
72
  # which adapter got picked decides socket reuse — Faraday's
62
73
  # default net_http one opens a connection per request. Naming it
63
74
  # is the cheapest way to make that discoverable.
64
- GraphWeaver::Internal::Log.log(:info) { "faraday transport #{@url} (adapter: #{@connection.builder.adapter})" }
75
+ GraphWeaver::Internal::Log.log(:info) { "faraday transport #{safe_url} (adapter: #{@connection.builder.adapter})" }
65
76
  end
66
77
 
67
78
  private
68
79
 
80
+ # Where requests actually go. Faraday moves a url's query string into
81
+ # the connection's default params and strips it from url_prefix, so
82
+ # url_prefix alone names an endpoint nothing posts to — and #url is what
83
+ # `graphql: :wire` stubs and what the boot log line prints. Faraday's own
84
+ # encoder, not URI.encode_www_form: only it spells an Array a[]=1&a[]=2
85
+ # and a Hash a[b]=c the way the request will.
86
+ def endpoint_url(connection)
87
+ connection.build_exclusive_url(nil, connection.params).to_s
88
+ end
89
+
69
90
  sig { override.params(body: String).returns(T::Array[T.untyped]) }
70
91
  def post(body)
71
92
  response = @connection.post do |request|
72
- # a prebuilt connection owns its headers — only fill the blanks
73
- DEFAULT_HEADERS.each { |name, value| request.headers[name] ||= value }
93
+ # a prebuilt connection owns its headers — only fill the blanks.
94
+ # Faraday pre-fills its stock User-Agent, so that one is never blank
95
+ # and graph_weaver's traffic attributed to Faraday; a connection that
96
+ # never chose one isn't expressing a preference.
97
+ request.headers.delete("User-Agent") if request.headers["User-Agent"] == ::Faraday::Connection::USER_AGENT
98
+ Transport.default_headers.each { |name, value| request.headers[name] ||= value }
99
+
100
+ # asked per request, so a token that expires is fetched now; nil
101
+ # drops the header, which is how an optional one says "not this time"
102
+ @dynamic.each do |name, value|
103
+ resolved = value.call
104
+ resolved.nil? ? request.headers.delete(name) : request.headers[name] = resolved.to_s
105
+ end
106
+
74
107
  request.body = body
75
108
  end
76
109