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.
- checksums.yaml +4 -4
- data/Gemfile +8 -0
- data/Gemfile.lock +153 -4
- data/README.md +45 -79
- data/docs/alternatives.md +195 -0
- data/docs/cassettes.md +61 -50
- data/docs/editors.md +32 -47
- data/docs/errors.md +360 -103
- data/docs/federation.md +692 -473
- data/docs/generated_modules.md +441 -314
- data/docs/getting_started.md +370 -194
- data/docs/i18n.md +171 -0
- data/docs/logging.md +197 -50
- data/docs/real_world.md +42 -27
- data/docs/scalars.md +307 -176
- data/docs/testing.md +473 -220
- data/docs/transports.md +224 -151
- data/docs/upgrading.md +258 -305
- data/examples/README.md +38 -0
- data/examples/countries.rb +39 -0
- data/examples/federation.rb +62 -0
- data/examples/github/generate.rb +20 -0
- data/examples/github/generated/star_mutation.rb +126 -0
- data/examples/github/generated/stargazers_query.rb +232 -0
- data/examples/github/generated/starred_query.rb +151 -0
- data/examples/github/queries/star.graphql +8 -0
- data/examples/github/queries/stargazers.graphql +22 -0
- data/examples/github/queries/starred.graphql +11 -0
- data/examples/github/run.rb +43 -0
- data/examples/github/setup.rb +18 -0
- data/examples/rick_and_morty.rb +57 -0
- data/graph_weaver.gemspec +19 -3
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +69 -11
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +406 -195
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +284 -46
- data/lib/graph_weaver/federation.rb +129 -27
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +27 -15
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +80 -0
- data/lib/graph_weaver/internal/headers.rb +70 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +40 -4
- data/lib/graph_weaver/internal.rb +249 -14
- data/lib/graph_weaver/log_subscriber.rb +74 -0
- data/lib/graph_weaver/logging.rb +163 -19
- data/lib/graph_weaver/query_module.rb +44 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +45 -13
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +266 -56
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +34 -10
- data/lib/graph_weaver/testing/endpoint.rb +107 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +164 -45
- data/lib/graph_weaver/testing/router.rb +64 -13
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +48 -6
- data/lib/graph_weaver/transport.rb +134 -27
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +495 -106
- metadata +71 -3
- data/CHANGELOG.md +0 -2355
|
@@ -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
|
|
@@ -50,6 +51,20 @@ module GraphWeaver
|
|
|
50
51
|
"ancestor, and only the gateway that planned the fetch knows what to put there. Run " \
|
|
51
52
|
"this one against a real router.",
|
|
52
53
|
],
|
|
54
|
+
incremental_delivery: [
|
|
55
|
+
"a response delivered in more than one payload",
|
|
56
|
+
"@defer/@stream stream the rest of the answer over a multipart body after the first " \
|
|
57
|
+
"payload, and this router answers in one. Run this one against a real router — the " \
|
|
58
|
+
"Apollo Router supports @defer behind an Accept header; @apollo/gateway doesn't " \
|
|
59
|
+
"know the directive at all.",
|
|
60
|
+
],
|
|
61
|
+
progressive_override: [
|
|
62
|
+
"a progressive @override still rolling out",
|
|
63
|
+
"federation 2.7's @override(label:) leaves both subgraphs resolving the field and " \
|
|
64
|
+
"splits traffic between them by a rule only the gateway evaluates — a local router " \
|
|
65
|
+
"would pick one and answer from it every time. Run this one against a real router, " \
|
|
66
|
+
"or finish the rollout (drop the label) first.",
|
|
67
|
+
],
|
|
53
68
|
chained_requires: [
|
|
54
69
|
"a @requires whose field set names another @requires field",
|
|
55
70
|
"the router satisfies a @requires with one fetch, so it can't first satisfy that " \
|
|
@@ -189,6 +204,8 @@ module GraphWeaver
|
|
|
189
204
|
# reset it yourself around the code path you're measuring.
|
|
190
205
|
class Router
|
|
191
206
|
include GraphWeaver::Parsing
|
|
207
|
+
# #context/#context= plus the lock over them — see the accessor below
|
|
208
|
+
include GraphWeaver::ContextSeam
|
|
192
209
|
|
|
193
210
|
# the schema the router serves — the supergraph with its composition
|
|
194
211
|
# machinery stripped, exactly what a real router exposes
|
|
@@ -214,9 +231,13 @@ module GraphWeaver
|
|
|
214
231
|
# subgraphs answered with fabricated data instead of that refusal
|
|
215
232
|
attr_reader :faked
|
|
216
233
|
|
|
217
|
-
#
|
|
218
|
-
# run as a different user without rebuilding the router
|
|
219
|
-
|
|
234
|
+
# The context handed to every subgraph — settable, so one example can
|
|
235
|
+
# run as a different user without rebuilding the router. A proc is
|
|
236
|
+
# answered from the request's headers, which only a wire supplies:
|
|
237
|
+
# `context: ->(headers) { { current_user: User.find_by(token:
|
|
238
|
+
# headers["Authorization"]) } }` served through {Endpoint}. The
|
|
239
|
+
# accessors, and the lock guarding them, come from
|
|
240
|
+
# {GraphWeaver::ContextSeam}.
|
|
220
241
|
|
|
221
242
|
# The planner injects key fields under this prefix, and the concrete
|
|
222
243
|
# __typename under that key; reading an answer back means stripping
|
|
@@ -235,7 +256,7 @@ module GraphWeaver
|
|
|
235
256
|
source = supergraph.to_s # a path, or the SDL itself — Pathname included
|
|
236
257
|
@schema = GraphWeaver::SchemaLoader.load(source)
|
|
237
258
|
@table = GraphWeaver::SchemaLoader.routing_table(source)
|
|
238
|
-
|
|
259
|
+
init_context_seam(context)
|
|
239
260
|
@trace = []
|
|
240
261
|
|
|
241
262
|
Unplannable.unsupported!(@table)
|
|
@@ -292,6 +313,12 @@ module GraphWeaver
|
|
|
292
313
|
return { "data" => nil, "errors" => [Internal::Wire.graphql_error(e.message, "GRAPHQL_PARSE_FAILED")] }
|
|
293
314
|
end
|
|
294
315
|
|
|
316
|
+
# above validation, which would otherwise answer "Directive @defer is
|
|
317
|
+
# not defined" — an accident of the composed schema, not this router's
|
|
318
|
+
# decision, and one that stops refusing the day a supergraph @links
|
|
319
|
+
# the defer spec
|
|
320
|
+
@planner.refuse_incremental!(document)
|
|
321
|
+
|
|
295
322
|
# validate the way a router does, so a stale query fails as it fails
|
|
296
323
|
# in production rather than somewhere inside the planner
|
|
297
324
|
errors = @planner.validate(document)
|
|
@@ -326,6 +353,12 @@ module GraphWeaver
|
|
|
326
353
|
# green — the same silent pass a typo'd override key is refused for.
|
|
327
354
|
def check_fake!(options)
|
|
328
355
|
options = options.to_h
|
|
356
|
+
# rspec's --seed already drives the fake, and a router is built once
|
|
357
|
+
# for the suite — a seed here would pin every example to one run
|
|
358
|
+
if options.key?(:seed) || options.key?("seed")
|
|
359
|
+
raise GraphWeaver::ConfigurationError, "seed: isn't a fake: option — `rspec --seed 1234` " \
|
|
360
|
+
"reproduces a run, and GraphWeaver::Testing.config.seed sets one for a harness that isn't rspec"
|
|
361
|
+
end
|
|
329
362
|
return options.freeze if options.empty? || @faked.any?
|
|
330
363
|
|
|
331
364
|
raise GraphWeaver::ConfigurationError, "fake: says how faked subgraphs fabricate, and this " \
|
|
@@ -352,7 +385,7 @@ module GraphWeaver
|
|
|
352
385
|
|
|
353
386
|
plan.steps.each do |step|
|
|
354
387
|
result = fetch_step(step, plan.operation, given)
|
|
355
|
-
Array(result["errors"]).each { |error| errors << rewrite(error) }
|
|
388
|
+
Array(result["errors"]).each { |error| errors << rewrite(error, step.subgraph) }
|
|
356
389
|
payload = result["data"]
|
|
357
390
|
if payload.nil?
|
|
358
391
|
# the subgraph nulled its whole response, so every field it was
|
|
@@ -433,10 +466,12 @@ module GraphWeaver
|
|
|
433
466
|
# plan is built once and reused, so only here are the variables known.
|
|
434
467
|
wanted = step.deferrals.select { |d| included?(d.node, variables) }
|
|
435
468
|
|
|
436
|
-
#
|
|
437
|
-
#
|
|
438
|
-
# a
|
|
439
|
-
|
|
469
|
+
# Everything crossing into one subgraph from this level rides one
|
|
470
|
+
# call, @requires and plain together, as Apollo's does. The exception
|
|
471
|
+
# is a prefetch that didn't answer for some node: the @requires half
|
|
472
|
+
# then runs over fewer nodes than the plain half, and two node sets
|
|
473
|
+
# can't share a fetch.
|
|
474
|
+
wanted.group_by { |d| [d.subgraph, d.requires.any? && blocked.any?] }.each do |(target, chained), deferrals|
|
|
440
475
|
fetched = chained ? nodes.reject { |(node, _)| blocked.include?(node.object_id) } : nodes
|
|
441
476
|
tree = Internal::Planner.field_tree(deferrals.flat_map(&:representation).uniq)
|
|
442
477
|
representations = fetched.map { |(node, _)| representation(node, tree, step.type_name) }
|
|
@@ -445,7 +480,7 @@ module GraphWeaver
|
|
|
445
480
|
if fetched.any?
|
|
446
481
|
result = entities_fetch(target, step.type_name, deferrals.map(&:node), representations, operation, variables)
|
|
447
482
|
entities = result.dig("data", "_entities") || []
|
|
448
|
-
Array(result["errors"]).each { |error| errors << rewrite(error, fetched) }
|
|
483
|
+
Array(result["errors"]).each { |error| errors << rewrite(error, target, fetched) }
|
|
449
484
|
end
|
|
450
485
|
|
|
451
486
|
fetched.each_with_index do |(node, _), index|
|
|
@@ -500,7 +535,7 @@ module GraphWeaver
|
|
|
500
535
|
|
|
501
536
|
result = entities_fetch(subgraph, step.type_name, selections, representations, operation, variables)
|
|
502
537
|
entities = result.dig("data", "_entities") || []
|
|
503
|
-
Array(result["errors"]).each { |error| errors << rewrite(error, nodes) }
|
|
538
|
+
Array(result["errors"]).each { |error| errors << rewrite(error, subgraph, nodes) }
|
|
504
539
|
|
|
505
540
|
nodes.each_with_index do |(node, _), index|
|
|
506
541
|
entity = entities[index]
|
|
@@ -556,7 +591,8 @@ module GraphWeaver
|
|
|
556
591
|
# is a path into the fetch, and `locations` a position in it. Re-path
|
|
557
592
|
# what can be re-pathed and drop what can't, rather than hand back a
|
|
558
593
|
# line number pointing into a document that doesn't exist.
|
|
559
|
-
def rewrite(error, nodes = nil)
|
|
594
|
+
def rewrite(error, subgraph, nodes = nil)
|
|
595
|
+
error = stamp(error, subgraph)
|
|
560
596
|
path = error["path"]
|
|
561
597
|
return error.except("locations") unless path.is_a?(Array)
|
|
562
598
|
|
|
@@ -565,6 +601,20 @@ module GraphWeaver
|
|
|
565
601
|
error.except("locations").merge("path" => prefix + unalias(stitched ? path[2..] : path))
|
|
566
602
|
end
|
|
567
603
|
|
|
604
|
+
# Which subgraph a bubbled error came from. Every real transport stamps
|
|
605
|
+
# this and a client branches on it to tell a downstream failure from an
|
|
606
|
+
# ordinary business error, so a test written against an unstamped one
|
|
607
|
+
# passes here and breaks in front of a gateway. Apollo Router's spelling
|
|
608
|
+
# (`extensions.service`); the deprecated JS gateway says `serviceName`
|
|
609
|
+
# and adds a DOWNSTREAM_SERVICE_ERROR code, which isn't ours to invent.
|
|
610
|
+
# Whatever the subgraph's own resolver set is left alone.
|
|
611
|
+
def stamp(error, subgraph)
|
|
612
|
+
extensions = error["extensions"].is_a?(Hash) ? error["extensions"] : {}
|
|
613
|
+
return error if extensions.key?("service") || extensions.key?("serviceName")
|
|
614
|
+
|
|
615
|
+
error.merge("extensions" => extensions.merge("service" => subgraph))
|
|
616
|
+
end
|
|
617
|
+
|
|
568
618
|
# The @key/@requires fields we inject are ours; an error path naming one
|
|
569
619
|
# points the caller at a field no schema has.
|
|
570
620
|
def unalias(path)
|
|
@@ -657,7 +707,8 @@ module GraphWeaver
|
|
|
657
707
|
end
|
|
658
708
|
|
|
659
709
|
GraphWeaver::Internal::Log.log_timed(:debug, "router -> #{name} #{tag} completed") do
|
|
660
|
-
@subgraphs.fetch(name).execute(query, variables:, operation_name:,
|
|
710
|
+
@subgraphs.fetch(name).execute(query, variables:, operation_name:,
|
|
711
|
+
context: Internal::Util.context!(@context)).to_h
|
|
661
712
|
end
|
|
662
713
|
end
|
|
663
714
|
|
data/lib/graph_weaver/testing.rb
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
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 =
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
|
154
|
+
unless CLIENT_MODES.include?(mode)
|
|
108
155
|
raise ArgumentError,
|
|
109
|
-
"default_mode: must be one of #{CLIENT_MODES.inspect}
|
|
110
|
-
"GraphWeaver.client
|
|
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
|
-
@
|
|
188
|
+
@built_routers = nil
|
|
141
189
|
end
|
|
142
190
|
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
#
|
|
155
|
-
#
|
|
156
|
-
#
|
|
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
|
|
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
|
-
|
|
235
|
+
path if path && GraphWeaver::Internal::Util.composed?(path)
|
|
236
|
+
end
|
|
163
237
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
172
|
-
# client already runs in-process. Only a
|
|
173
|
-
# there is nothing else to fall back to:
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
#
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
221
|
-
# catch it here — while the block that set it is still on
|
|
222
|
-
|
|
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
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
-
|
|
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
|
|
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 #{
|
|
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
|
-
|
|
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
|
|