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
data/lib/graph_weaver/rspec.rb
CHANGED
|
@@ -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
|
-
#
|
|
23
|
-
#
|
|
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 —
|
|
29
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
40
|
-
# GraphWeaver.client.
|
|
41
|
-
# - :in_process runs against config.schema, or the
|
|
42
|
-
# client already uses. Only a live class has resolvers,
|
|
43
|
-
#
|
|
44
|
-
# - :router plans against the composed supergraph
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
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
|
|
53
|
-
# untagged one;
|
|
54
|
-
#
|
|
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
|
|
69
|
+
# config.context, reaching every stand-in the example runs through, and
|
|
70
|
+
# reset between examples.
|
|
58
71
|
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
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
|
-
#
|
|
63
|
-
#
|
|
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
|
-
|
|
92
|
-
|
|
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(", ")}
|
|
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
|
-
#
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
#
|
|
140
|
-
#
|
|
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
|
-
#
|
|
163
|
-
#
|
|
164
|
-
|
|
165
|
-
|
|
406
|
+
# With more than one graph, `graph:` says which one's modules this
|
|
407
|
+
# fake stands in for — pins 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]
|
|
168
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
#
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
476
|
+
stand_in!(router, graphs)
|
|
213
477
|
end
|
|
214
478
|
|
|
215
|
-
# A tag and a helper are two spellings of one choice
|
|
216
|
-
#
|
|
217
|
-
#
|
|
218
|
-
#
|
|
219
|
-
#
|
|
220
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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::
|
|
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::
|
|
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
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|