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/tasks.rb
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
# queries — so which question you're asking is the task name:
|
|
15
15
|
#
|
|
16
16
|
# rake graph_weaver:generate # queries_paths -> generated_paths.first
|
|
17
|
+
# rake graph_weaver:graphs # which graphs this app has, and where each writes
|
|
17
18
|
# rake graph_weaver:verify # fail if generated files are stale (CI)
|
|
19
|
+
# rake graph_weaver:unused # report selections no code reads
|
|
18
20
|
# rake graph_weaver:queries:check # fail if a query no longer validates (CI)
|
|
19
21
|
# rake graph_weaver:schema:diff # fail if the server has drifted from the dump
|
|
20
22
|
# rake graph_weaver:schema:refresh # re-introspect and rewrite the dump
|
|
@@ -28,20 +30,144 @@ module GraphWeaver
|
|
|
28
30
|
module Internal
|
|
29
31
|
# helpers the rake tasks share
|
|
30
32
|
module Tasks
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
# What a federation task runs over: SUPERGRAPH= for one run, else every
|
|
34
|
+
# declared graph that names a composed supergraph — an app that said
|
|
35
|
+
# where its supergraph is has already answered. Each entry is the graph
|
|
36
|
+
# and its supergraph; SUPERGRAPH= has no graph behind it, so a
|
|
37
|
+
# single-schema app's output is what it always was.
|
|
38
|
+
def self.supergraphs!(task)
|
|
39
|
+
return [[nil, ENV["SUPERGRAPH"]]] if ENV["SUPERGRAPH"]
|
|
40
|
+
|
|
41
|
+
found = GraphWeaver.graphs.filter_map do |graph|
|
|
42
|
+
supergraph = graph.supergraph
|
|
43
|
+
[graph, supergraph] if supergraph
|
|
44
|
+
end
|
|
45
|
+
found.empty? ? abort(no_supergraph(task)) : found
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The other half of :diff — every task here walks the supergraph's
|
|
49
|
+
# subgraph list, so a subgraph dropped from the composition whose Ruby
|
|
50
|
+
# class is still loaded was invisible to all of them. Asked once over
|
|
51
|
+
# the whole run: a multi-graph app's other supergraph places its own
|
|
52
|
+
# schemas, so only one no supergraph here has a subgraph for is named.
|
|
53
|
+
#
|
|
54
|
+
# A warning, not a failure. A process that loads a subgraph of a
|
|
55
|
+
# supergraph this run never reads is the same picture — the gem's own
|
|
56
|
+
# suite is one — and failing on it would break a setup that is fine.
|
|
57
|
+
def self.warn_unplaced(drifts)
|
|
58
|
+
unplaced = drifts.map(&:unplaced).reduce(:&).to_a
|
|
59
|
+
return if unplaced.empty?
|
|
60
|
+
|
|
61
|
+
names = drifts.flat_map(&:subgraphs).uniq.sort
|
|
62
|
+
warn "", "not placed — no subgraph of any supergraph read here is:",
|
|
63
|
+
*unplaced.map { |schema| " #{schema.name}" },
|
|
64
|
+
"A subgraph retired from a composition leaves exactly this behind, and so does a " \
|
|
65
|
+
"process that loads a subgraph of a supergraph nobody here reads — which is why " \
|
|
66
|
+
"this is a warning and not drift. The subgraphs read here: #{names.join(", ")}."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# SUPERGRAPH= names the supergraph the federation:* tasks run over, and
|
|
70
|
+
# nothing else can honour it: every other task reads the schema its
|
|
71
|
+
# graphs declare, and pointing one at an ad-hoc supergraph would collapse
|
|
72
|
+
# a multi-graph app into a single unnamed graph — which for `generate`
|
|
73
|
+
# means pruning the generated files of every graph it didn't cover. So
|
|
74
|
+
# refuse, rather than report a verdict about a different schema than the
|
|
75
|
+
# one just typed.
|
|
76
|
+
def self.refuse_supergraph_flag!
|
|
77
|
+
return unless ENV["SUPERGRAPH"]
|
|
78
|
+
|
|
79
|
+
abort "SUPERGRAPH= applies to the federation:* tasks (diff, subgraphs, coverage) — " \
|
|
80
|
+
"every other task reads the schema its graph declares. Name it there so every run " \
|
|
81
|
+
"finds it: GraphWeaver.graph(:api) { schema \"supergraph.graphql\" }."
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The spellings a CI config turns a flag off with. STRICT=0 used to be
|
|
85
|
+
# "set, therefore on" — the one answer nobody means by it.
|
|
86
|
+
OFF = %w[0 false no off].freeze
|
|
87
|
+
|
|
88
|
+
# An ENV flag, as a boolean: unset, empty and the OFF spellings are off,
|
|
89
|
+
# anything else is on.
|
|
90
|
+
def self.flag?(name)
|
|
91
|
+
value = ENV[name].to_s.strip
|
|
92
|
+
!value.empty? && !OFF.include?(value.downcase)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Every directory the app's graphs read queries from, and whether any
|
|
96
|
+
# holds one. Two tasks reach an empty app and both should say the same
|
|
97
|
+
# sentence about it rather than report 0 of 0 and advise a generate that
|
|
98
|
+
# would generate nothing.
|
|
99
|
+
def self.query_dirs = GraphWeaver.graphs.flat_map(&:queries).uniq
|
|
100
|
+
|
|
101
|
+
def self.no_queries = "no queries in #{query_dirs.join(", ")}"
|
|
102
|
+
|
|
103
|
+
def self.queries? = GraphWeaver::Internal::Util.query_files(query_dirs).any?
|
|
104
|
+
|
|
105
|
+
# A section heading, so a multi-graph app can tell whose report it is
|
|
106
|
+
# reading. Nothing for the default graph: a single-schema app never
|
|
107
|
+
# said the word "graph" and its output shouldn't either.
|
|
108
|
+
def self.heading(graph) = ("graph #{graph.name.inspect}" if graph&.name)
|
|
109
|
+
|
|
110
|
+
# Which graphs a verdict is about — "graph :a, graph :b: " — or nothing
|
|
111
|
+
# at all, so a single-schema app's aborts read exactly as they did.
|
|
112
|
+
def self.whose(graphs)
|
|
113
|
+
named = graphs.filter_map { |graph| heading(graph) }
|
|
114
|
+
named.empty? ? "" : "#{named.join(", ")}: "
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Nothing composed anywhere. Says where it looked — one line per graph,
|
|
118
|
+
# because the adopter's question is "why didn't it find mine" — then the
|
|
119
|
+
# two ways to answer it.
|
|
120
|
+
def self.no_supergraph(task)
|
|
121
|
+
declared = GraphWeaver.graphs
|
|
122
|
+
example = declared.map(&:name).compact.first || :api
|
|
123
|
+
["no composed supergraph here — a federation task reads the @join__* routing table, " \
|
|
124
|
+
"and nothing this app declares carries one:",
|
|
125
|
+
*declared.map { |graph| " #{looked_at(graph)}" },
|
|
126
|
+
"Pass one for this run — rake graph_weaver:federation:#{task} " \
|
|
127
|
+
"SUPERGRAPH=supergraph.graphql — or name it where the graph is declared, so every " \
|
|
128
|
+
"run finds it: GraphWeaver.graph(#{example.inspect}) { schema \"supergraph.graphql\" }."]
|
|
129
|
+
.join("\n")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Where one graph's schema came from, in the three shapes it comes in.
|
|
133
|
+
def self.looked_at(graph)
|
|
134
|
+
label = graph.name ? "graph #{graph.name.inspect}" : "this app's schema"
|
|
135
|
+
path = graph.dump_path
|
|
136
|
+
return "#{label}: #{GraphWeaver::Internal::Util.relative(path)}" if path
|
|
137
|
+
|
|
138
|
+
live = graph.live_schema
|
|
139
|
+
found =
|
|
140
|
+
if live then "#{live.name}, a live class — composition is what writes a routing table"
|
|
141
|
+
else "nothing on disk at #{GraphWeaver.schema_path}"
|
|
142
|
+
end
|
|
143
|
+
"#{label}: #{found}"
|
|
38
144
|
end
|
|
145
|
+
private_class_method :looked_at
|
|
39
146
|
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
|
|
147
|
+
# What the run found worth saying about the registry, once each: the
|
|
148
|
+
# registrations it couldn't match, and the scalars nothing registered.
|
|
149
|
+
# The logger is the runtime channel and is silent by default (in Rails it
|
|
150
|
+
# writes to a file); this task's own output is the build channel, and the
|
|
151
|
+
# build is where someone regenerating is looking — so both advisories go
|
|
152
|
+
# there rather than one each way.
|
|
153
|
+
def self.report_registry
|
|
44
154
|
GraphWeaver.unmatched_registrations.each { |message| puts message }
|
|
155
|
+
report = GraphWeaver::Internal::Util.untyped_scalars_report(GraphWeaver.untyped_scalars_by_graph)
|
|
156
|
+
puts report if report
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# What one graph registers, for the graphs task — the answer an app
|
|
160
|
+
# otherwise reads out of every initializer by hand. A pre-registered
|
|
161
|
+
# built-in isn't app intent; one an app has REPLACED is, which is why
|
|
162
|
+
# this asks whether the entry is still the library's rather than
|
|
163
|
+
# dropping the built-in names.
|
|
164
|
+
def self.registrations(graph)
|
|
165
|
+
registry = graph.registry
|
|
166
|
+
{
|
|
167
|
+
"scalars" => registry.scalar_registry.keys.reject { |name| registry.builtin_scalar?(name) },
|
|
168
|
+
"enums" => registry.enum_registry.keys,
|
|
169
|
+
"extend_type" => registry.type_registry.keys,
|
|
170
|
+
}.filter_map { |kind, names| " #{kind}: #{names.sort.join(", ")}" if names.any? }
|
|
45
171
|
end
|
|
46
172
|
|
|
47
173
|
# Neither task that needs the committed dump can take one itself, so both
|
|
@@ -50,6 +176,35 @@ module GraphWeaver
|
|
|
50
176
|
"no schema dump at #{GraphWeaver.schema_path} — take one: " \
|
|
51
177
|
"rake graph_weaver:schema:refresh URL=https://api.example.com/graphql"
|
|
52
178
|
end
|
|
179
|
+
|
|
180
|
+
# What the schema tasks work on, one entry per graph: the dump
|
|
181
|
+
# generation reads and the source behind it.
|
|
182
|
+
#
|
|
183
|
+
# One rule — the dump is the contract generation reads; `refresh`
|
|
184
|
+
# rewrites it from the graph's source, `diff` says how far that source
|
|
185
|
+
# has drifted from it, whichever the source is. A graph whose schema IS
|
|
186
|
+
# a live class reads no dump at all, so it has neither.
|
|
187
|
+
def self.dumps = GraphWeaver.graphs.map { |graph| [graph, graph.dump_path, graph.dump_source] }
|
|
188
|
+
|
|
189
|
+
# A graph that generates straight from a schema class has no dump
|
|
190
|
+
# between the code and the output — so there is nothing here to
|
|
191
|
+
# refresh or compare, and nothing that can be stale.
|
|
192
|
+
def self.no_dump_needed(graph, source)
|
|
193
|
+
whose = heading(graph) || "this app"
|
|
194
|
+
# a dump this graph names and hasn't written yet — refresh writes it
|
|
195
|
+
# from the url its modules post to, so there is one task to name
|
|
196
|
+
if (missing = graph.named_dump_path)
|
|
197
|
+
return "#{whose}: no schema dump at #{missing} yet — take one: " \
|
|
198
|
+
"rake graph_weaver:schema:refresh"
|
|
199
|
+
end
|
|
200
|
+
return "#{whose}: #{no_dump}" unless source
|
|
201
|
+
|
|
202
|
+
"#{whose} generates from #{source_name(source)} directly — no dump to keep in step"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# How a dump's source reads in a report: a url as itself, a schema
|
|
206
|
+
# class by name.
|
|
207
|
+
def self.source_name(source) = source.is_a?(Module) ? GraphWeaver::SchemaLoader.endpoint(source) : source
|
|
53
208
|
end
|
|
54
209
|
end
|
|
55
210
|
end
|
|
@@ -73,13 +228,22 @@ namespace :graph_weaver do
|
|
|
73
228
|
GraphWeaver.skip_generated_load = false
|
|
74
229
|
end
|
|
75
230
|
|
|
231
|
+
# Plumbing, like :environment: the dependency every task that reads the
|
|
232
|
+
# schema its graphs declare carries, so a SUPERGRAPH= it cannot honour is
|
|
233
|
+
# refused once rather than ignored nine times.
|
|
234
|
+
task own_schema: :environment do
|
|
235
|
+
GraphWeaver::Internal::Tasks.refuse_supergraph_flag!
|
|
236
|
+
end
|
|
237
|
+
|
|
76
238
|
# the default, not GraphWeaver.queries_paths: a desc is baked when this file
|
|
77
239
|
# loads, which in Rails is before :environment has run an initializer that
|
|
78
240
|
# moves it — interpolating would print the default as though it were the setting
|
|
79
241
|
desc "Generate typed query modules (default app/graphql/queries -> app/graphql/generated)"
|
|
80
|
-
task generate: :
|
|
81
|
-
|
|
82
|
-
|
|
242
|
+
task generate: :own_schema do
|
|
243
|
+
# every graph's output, not just the default one's: this is the "pruned"
|
|
244
|
+
# report, and a file deleted in one graph is as much a diff as in another
|
|
245
|
+
globs = GraphWeaver.graphs.map { |graph| File.join(GraphWeaver::Internal::Util.resolve(graph.output), "**/*.rb") }
|
|
246
|
+
before = globs.flat_map { |glob| Dir[glob] }
|
|
83
247
|
|
|
84
248
|
# schema auto-located at GraphWeaver.schema_path, any supported extension
|
|
85
249
|
written = GraphWeaver.generate!
|
|
@@ -88,57 +252,149 @@ namespace :graph_weaver do
|
|
|
88
252
|
puts "#{written.size - changed.size} already up to date" if changed.size < written.size
|
|
89
253
|
# generated files are checked in, so a delete this task made is a diff the
|
|
90
254
|
# user is about to find; a run that printed nothing at all had done both
|
|
91
|
-
(before - Dir[glob]).each
|
|
92
|
-
|
|
93
|
-
|
|
255
|
+
(before - globs.flat_map { |glob| Dir[glob] }).each do |path|
|
|
256
|
+
puts "pruned #{GraphWeaver::Internal::Util.relative(path)}"
|
|
257
|
+
end
|
|
258
|
+
puts GraphWeaver::Internal::Tasks.no_queries if written.empty?
|
|
259
|
+
GraphWeaver::Internal::Tasks.report_registry
|
|
94
260
|
rescue GraphWeaver::Error => e
|
|
95
261
|
# a typo'd query is a user error — the message names file, position and
|
|
96
262
|
# fix, and a rake backtrace through codegen only buries it
|
|
97
263
|
abort e.message
|
|
98
264
|
end
|
|
99
265
|
|
|
266
|
+
# `rake -T` can't name them: a desc is baked when this file loads, and in
|
|
267
|
+
# Rails that is before :environment, so before the initializer that declares
|
|
268
|
+
# them has run. This is the task that can.
|
|
269
|
+
desc "List the configured graphs and where each one generates"
|
|
270
|
+
task graphs: :own_schema do
|
|
271
|
+
GraphWeaver.graphs.each do |graph|
|
|
272
|
+
name = graph.name ? graph.name.inspect : "(the default graph — GraphWeaver's own settings)"
|
|
273
|
+
puts "#{name} #{Array(graph.queries).join(", ")} -> #{GraphWeaver::Internal::Util.relative(graph.output)}"
|
|
274
|
+
puts " namespace: #{graph.namespace}" if graph.namespace
|
|
275
|
+
# which server a graph's modules call — the one thing this task couldn't
|
|
276
|
+
# say. A graph that bakes none falls back to GraphWeaver.client, which is
|
|
277
|
+
# an app-wide setting and not this task's subject.
|
|
278
|
+
puts " client: #{graph.client}" if graph.client
|
|
279
|
+
# a registration is scoped to one graph, and nothing else says which
|
|
280
|
+
GraphWeaver::Internal::Tasks.registrations(graph).each { |line| puts line }
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
100
284
|
desc "Verify generated query modules are up to date"
|
|
101
|
-
task verify: :
|
|
285
|
+
task verify: :own_schema do
|
|
102
286
|
GraphWeaver.verify_generated!
|
|
103
287
|
puts "generated queries up to date"
|
|
104
|
-
GraphWeaver::Internal::Tasks.
|
|
288
|
+
GraphWeaver::Internal::Tasks.report_registry
|
|
289
|
+
rescue GraphWeaver::Error => e
|
|
290
|
+
abort e.message
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# The over-fetch nothing else here can see. verify says the Ruby matches the
|
|
294
|
+
# query; this asks whether the query still asks for what the app uses — the
|
|
295
|
+
# drift that happens on the app's side, silently, when a template stops
|
|
296
|
+
# reading a field. Nothing is edited, and the default exit is 0: a finding is
|
|
297
|
+
# a prompt to look.
|
|
298
|
+
desc "Report selections whose generated props no code reads (PATHS= to scope, STRICT=1 to fail)"
|
|
299
|
+
task unused: :own_schema do
|
|
300
|
+
require "graph_weaver/internal/unused"
|
|
301
|
+
|
|
302
|
+
# an app with no queries has nothing to over-fetch: sweeping it to report
|
|
303
|
+
# 0 of 0 and advise `rake graph_weaver:generate` answers a question nobody
|
|
304
|
+
# asked, where its sibling says the one true thing in one line
|
|
305
|
+
next puts GraphWeaver::Internal::Tasks.no_queries unless GraphWeaver::Internal::Tasks.queries?
|
|
306
|
+
|
|
307
|
+
# like cassettes:check, this reads generated modules — they are what says
|
|
308
|
+
# which props a query produced
|
|
309
|
+
GraphWeaver.load_generated!
|
|
310
|
+
unused = GraphWeaver::Internal::Unused.new(paths: ENV["PATHS"]&.split(","))
|
|
311
|
+
puts unused.report
|
|
312
|
+
|
|
313
|
+
# abort writes to unbuffered stderr; the report above went to
|
|
314
|
+
# block-buffered stdout, so a piped CI log shows it first
|
|
315
|
+
$stdout.flush
|
|
316
|
+
count = unused.findings.size
|
|
317
|
+
if count.positive? && GraphWeaver::Internal::Tasks.flag?("STRICT")
|
|
318
|
+
abort "#{count} #{(count == 1) ? "selection" : "selections"} nothing reads — drop " \
|
|
319
|
+
"#{(count == 1) ? "it" : "them"} from the query and regenerate (rake graph_weaver:generate)"
|
|
320
|
+
end
|
|
105
321
|
rescue GraphWeaver::Error => e
|
|
106
322
|
abort e.message
|
|
107
323
|
end
|
|
108
324
|
|
|
109
325
|
namespace :schema do
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
326
|
+
# One rule, per graph: the dump is the contract generation reads;
|
|
327
|
+
# :refresh rewrites it from the graph's source and :diff says how far
|
|
328
|
+
# that source has drifted from it — whichever the source is. A url is
|
|
329
|
+
# re-introspected (GRAPHWEAVER_AUTH supplies a token for private APIs);
|
|
330
|
+
# a graphql-ruby schema class this process runs answers introspection
|
|
331
|
+
# itself, so an app that serves its own schema needs no network.
|
|
332
|
+
|
|
333
|
+
desc "Fail when the schema behind the dump has drifted from it"
|
|
334
|
+
task diff: :own_schema do
|
|
335
|
+
subjects = GraphWeaver::Internal::Tasks.dumps
|
|
336
|
+
abort GraphWeaver::Internal::Tasks.no_dump if subjects.none? { |_, path, _| path }
|
|
337
|
+
|
|
338
|
+
stale = subjects.filter_map do |graph, path, source|
|
|
339
|
+
heading = GraphWeaver::Internal::Tasks.heading(graph)
|
|
340
|
+
puts heading if heading
|
|
341
|
+
# a graph that names a live class generates straight from it: no
|
|
342
|
+
# dump between the code and the output, so nothing can be stale
|
|
343
|
+
next puts GraphWeaver::Internal::Tasks.no_dump_needed(graph, source) unless path
|
|
344
|
+
|
|
345
|
+
# a schema class answers introspection itself; left nil, diff builds
|
|
346
|
+
# the dump's own transport, auth and all
|
|
347
|
+
diff = GraphWeaver::SchemaLoader.diff(path, transport: (source if source.is_a?(Module)))
|
|
348
|
+
dump = GraphWeaver::Internal::Util.relative(path)
|
|
349
|
+
next puts "#{dump} matches #{GraphWeaver::Internal::Tasks.source_name(source)}" if diff.empty?
|
|
350
|
+
|
|
121
351
|
puts diff.report
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
352
|
+
dump
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# abort writes to unbuffered stderr; the summaries above went to
|
|
356
|
+
# block-buffered stdout, so a piped CI log shows them first
|
|
357
|
+
$stdout.flush
|
|
358
|
+
unless stale.empty?
|
|
359
|
+
abort "#{stale.join(", ")} is stale — the schema behind it has drifted " \
|
|
360
|
+
"(rake graph_weaver:schema:refresh)"
|
|
126
361
|
end
|
|
127
362
|
rescue GraphWeaver::Error => e
|
|
128
|
-
# e.g. a dump
|
|
363
|
+
# e.g. a dump that is its own source — same clean exit as :refresh
|
|
129
364
|
abort e.message
|
|
130
365
|
end
|
|
131
366
|
|
|
132
|
-
desc "
|
|
133
|
-
task refresh: :
|
|
367
|
+
desc "Rewrite the local dump from the schema behind it (URL= to bootstrap the first one)"
|
|
368
|
+
task refresh: :own_schema do
|
|
134
369
|
# anything else in URL= reaches introspection as a schema *source*, and
|
|
135
370
|
# fails talking about file extensions rather than the flag just typed
|
|
136
371
|
if ENV["URL"] && !ENV["URL"].match?(GraphWeaver::Client::URL)
|
|
137
372
|
abort "URL= takes an endpoint: rake graph_weaver:schema:refresh URL=https://api.example.com/graphql"
|
|
138
373
|
end
|
|
139
374
|
|
|
140
|
-
|
|
141
|
-
|
|
375
|
+
# URL= names one endpoint, so it bootstraps the conventional dump —
|
|
376
|
+
# before any graph has a dump to read a source off
|
|
377
|
+
if ENV["URL"]
|
|
378
|
+
path, source = GraphWeaver::SchemaLoader.refresh!(url: ENV["URL"])
|
|
379
|
+
next puts "refreshed #{GraphWeaver::Internal::Util.relative(path)} from #{source}"
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
GraphWeaver::Internal::Tasks.dumps.each do |graph, path, source|
|
|
383
|
+
heading = GraphWeaver::Internal::Tasks.heading(graph)
|
|
384
|
+
puts heading if heading
|
|
385
|
+
# a graph that names a live class generates straight from it — no
|
|
386
|
+
# dump to write. One that names a dump it hasn't got yet has one, and
|
|
387
|
+
# its source is the url its modules already post to: that bootstraps a
|
|
388
|
+
# second graph's dump, which URL= can't, naming one endpoint for an
|
|
389
|
+
# app with one per graph. The default graph names neither, so it lands
|
|
390
|
+
# below and refresh! bootstraps its first dump (or says how).
|
|
391
|
+
path ||= graph.named_dump_path
|
|
392
|
+
next puts GraphWeaver::Internal::Tasks.no_dump_needed(graph, source) if !path && graph.named_schema?
|
|
393
|
+
|
|
394
|
+
written, from = GraphWeaver::SchemaLoader.refresh!(url: (source unless source.is_a?(Module)),
|
|
395
|
+
schema: (source if source.is_a?(Module)), path:)
|
|
396
|
+
puts "refreshed #{GraphWeaver::Internal::Util.relative(written)} from #{from}"
|
|
397
|
+
end
|
|
142
398
|
rescue GraphWeaver::Error => e
|
|
143
399
|
abort e.message
|
|
144
400
|
end
|
|
@@ -146,7 +402,7 @@ namespace :graph_weaver do
|
|
|
146
402
|
|
|
147
403
|
namespace :queries do
|
|
148
404
|
desc "Report checked-in queries that no longer validate against the server's schema"
|
|
149
|
-
task check: :
|
|
405
|
+
task check: :own_schema do
|
|
150
406
|
failures = GraphWeaver.check_queries
|
|
151
407
|
failures.each do |path, errors|
|
|
152
408
|
puts path
|
|
@@ -176,73 +432,100 @@ namespace :graph_weaver do
|
|
|
176
432
|
end
|
|
177
433
|
|
|
178
434
|
# needs no network, so it gates a PR the way verify does
|
|
179
|
-
desc "Fail when a subgraph here changed and the supergraph wasn't recomposed (SUPERGRAPH=)"
|
|
435
|
+
desc "Fail when a subgraph here changed and the supergraph wasn't recomposed (SUPERGRAPH= overrides)"
|
|
180
436
|
task diff: :loaded do
|
|
181
437
|
require "graph_weaver/federation"
|
|
182
438
|
|
|
183
|
-
supergraph
|
|
184
|
-
|
|
185
|
-
|
|
439
|
+
# every graph's supergraph is its own gate: one that drifted fails the
|
|
440
|
+
# run whatever its neighbours say, and so does one that checked nothing
|
|
441
|
+
checked = GraphWeaver::Internal::Tasks.supergraphs!("diff").map do |graph, supergraph|
|
|
442
|
+
heading = GraphWeaver::Internal::Tasks.heading(graph)
|
|
443
|
+
puts heading if heading
|
|
444
|
+
drift = GraphWeaver::Federation::Drift.new(supergraph:)
|
|
445
|
+
puts drift.report
|
|
446
|
+
puts if heading
|
|
447
|
+
[graph, drift]
|
|
448
|
+
end
|
|
186
449
|
|
|
187
450
|
# A partly-local supergraph is a supported setup, so a subgraph this
|
|
188
451
|
# process doesn't serve isn't a failure — but comparing against NONE
|
|
189
452
|
# of them is: the gate passes whatever the subgraphs say, which is
|
|
190
453
|
# worse than failing.
|
|
191
454
|
$stdout.flush
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
"
|
|
197
|
-
"task from CI: there is nothing here for it to gate."
|
|
455
|
+
# not .any? — the default graph is nil in this slot, and [nil].any? is false
|
|
456
|
+
stale = checked.select { |_, drift| drift.drift? }.map(&:first)
|
|
457
|
+
unless stale.empty?
|
|
458
|
+
abort "#{GraphWeaver::Internal::Tasks.whose(stale)}the supergraph is out of date — " \
|
|
459
|
+
"recompose it and commit the result"
|
|
198
460
|
end
|
|
461
|
+
vacuous = checked.select { |_, drift| drift.vacuous? }.map(&:first)
|
|
462
|
+
unless vacuous.empty?
|
|
463
|
+
abort "#{GraphWeaver::Internal::Tasks.whose(vacuous)}this checked nothing, so it proved " \
|
|
464
|
+
"nothing. No schema in this process defines what the supergraph says any of its " \
|
|
465
|
+
"subgraphs resolves — load them (in Rails, that is config.eager_load / " \
|
|
466
|
+
"config.rake_eager_load), or, if they all run elsewhere, drop this task from CI: there " \
|
|
467
|
+
"is nothing here for it to gate."
|
|
468
|
+
end
|
|
469
|
+
GraphWeaver::Internal::Tasks.warn_unplaced(checked.map(&:last))
|
|
199
470
|
rescue GraphWeaver::Error => e
|
|
200
471
|
abort e.message
|
|
201
472
|
end
|
|
202
473
|
|
|
203
|
-
desc "Show which loaded schema serves each subgraph, as a paste-ready map (SUPERGRAPH=)"
|
|
474
|
+
desc "Show which loaded schema serves each subgraph, as a paste-ready map (SUPERGRAPH= overrides)"
|
|
204
475
|
task subgraphs: :loaded do
|
|
205
476
|
require "graph_weaver/testing"
|
|
206
477
|
|
|
207
|
-
|
|
478
|
+
GraphWeaver::Internal::Tasks.supergraphs!("subgraphs").each do |graph, supergraph|
|
|
479
|
+
heading = GraphWeaver::Internal::Tasks.heading(graph)
|
|
480
|
+
puts heading if heading
|
|
208
481
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
482
|
+
# Testing::Router derives this map itself; this is for reading what
|
|
483
|
+
# detection sees when it refuses, and for committing the map instead.
|
|
484
|
+
table = GraphWeaver::SchemaLoader.routing_table(supergraph)
|
|
485
|
+
rows = table.subgraphs.map do |name|
|
|
486
|
+
found = GraphWeaver::Internal::Subgraphs.candidates(table, name)
|
|
487
|
+
sought = GraphWeaver::Internal::Subgraphs.expected(table, name)
|
|
488
|
+
[name, found, sought]
|
|
489
|
+
end
|
|
490
|
+
width = rows.map { |name, found, _| %("#{name}" => #{found.first&.name || "nil"},).length }.max
|
|
491
|
+
|
|
492
|
+
puts "subgraphs: {"
|
|
493
|
+
rows.each do |name, found, sought|
|
|
494
|
+
entry = %( "#{name}" => #{found.one? ? found.first.name : "nil"},).ljust(width + 2)
|
|
495
|
+
# fields first: every schema has a Query, so only the fields say why
|
|
496
|
+
evidence = (sought.grep(/\./) | sought).first(3).join(", ")
|
|
497
|
+
note = if found.one?
|
|
498
|
+
"# matched: defines #{evidence}"
|
|
499
|
+
elsif found.any?
|
|
500
|
+
"# AMBIGUOUS: #{found.map(&:name).sort.join(", ")} all match — pick one"
|
|
501
|
+
else
|
|
502
|
+
"# no loaded schema defines #{evidence} — fill this in"
|
|
503
|
+
end
|
|
504
|
+
puts "#{entry} #{note}"
|
|
230
505
|
end
|
|
231
|
-
puts "
|
|
506
|
+
puts "}"
|
|
507
|
+
puts if heading
|
|
232
508
|
end
|
|
233
|
-
puts "}"
|
|
234
509
|
rescue GraphWeaver::Error => e
|
|
235
510
|
abort e.message
|
|
236
511
|
end
|
|
237
512
|
|
|
238
|
-
desc "Report how many queries the local test router can plan (SUPERGRAPH
|
|
513
|
+
desc "Report how many queries the local test router can plan (SUPERGRAPH=/QUERIES= override)"
|
|
239
514
|
task coverage: :loaded do
|
|
240
515
|
require "graph_weaver/testing"
|
|
241
516
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
517
|
+
GraphWeaver::Internal::Tasks.supergraphs!("coverage").each do |graph, supergraph|
|
|
518
|
+
heading = GraphWeaver::Internal::Tasks.heading(graph)
|
|
519
|
+
puts heading if heading
|
|
520
|
+
# the graph's own queries, not the top-level setting: a graph that
|
|
521
|
+
# names its own supergraph names its own queries too, and measuring
|
|
522
|
+
# the neighbour's against this one reports a coverage nobody has
|
|
523
|
+
puts GraphWeaver::Testing::Coverage.new(
|
|
524
|
+
supergraph:,
|
|
525
|
+
queries: ENV["QUERIES"] || (graph ? graph.queries : GraphWeaver.queries_paths),
|
|
526
|
+
).report
|
|
527
|
+
puts if heading
|
|
528
|
+
end
|
|
246
529
|
rescue GraphWeaver::Error => e
|
|
247
530
|
# a supergraph the routing table can't read fully is itself the answer:
|
|
248
531
|
# nothing is plannable, and the message says which construct
|
|
@@ -258,16 +541,22 @@ namespace :graph_weaver do
|
|
|
258
541
|
# as a cast error naming a struct and a sorbet frame — nothing points at
|
|
259
542
|
# the stale file.
|
|
260
543
|
desc "Fail when a recorded response no longer casts into the generated structs"
|
|
261
|
-
task check: :
|
|
544
|
+
task check: :own_schema do
|
|
262
545
|
require "graph_weaver/testing"
|
|
263
546
|
|
|
264
547
|
# unlike its siblings this task reads generated modules — they are what
|
|
265
548
|
# a recording is checked against
|
|
266
549
|
GraphWeaver.load_generated!
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
550
|
+
# asked of each graph, not of the top-level settings: a graph's queries
|
|
551
|
+
# are its own and its constants live under its namespace, so a top-level
|
|
552
|
+
# lookup finds nothing in a namespaced app and then refuses for having
|
|
553
|
+
# checked nothing
|
|
554
|
+
modules = GraphWeaver.graphs.flat_map do |graph|
|
|
555
|
+
GraphWeaver::Internal::Util.query_files(graph.queries).filter_map do |path|
|
|
556
|
+
name = graph.generated_names(path, File.read(path)).first
|
|
557
|
+
Object.const_get(name) if Object.const_defined?(name)
|
|
558
|
+
end
|
|
559
|
+
end.uniq
|
|
271
560
|
|
|
272
561
|
# Testing.cassette_dir, not config.cassette_dir: the configured path is
|
|
273
562
|
# relative by default and rake runs from wherever it runs from
|
|
@@ -281,10 +570,12 @@ namespace :graph_weaver do
|
|
|
281
570
|
stale = checks.sum { |check| check.stale.size }
|
|
282
571
|
$stdout.flush
|
|
283
572
|
if stale.positive?
|
|
573
|
+
# the structs move when a REGISTRATION moves, not only when the dump
|
|
574
|
+
# does — and re-recording a fresh response doesn't fix that half
|
|
284
575
|
abort "#{stale} stale #{(stale == 1) ? "recording" : "recordings"} — the recorded server's " \
|
|
285
|
-
"answers no longer fit the structs generated from your schema.
|
|
286
|
-
"
|
|
287
|
-
"
|
|
576
|
+
"answers no longer fit the structs generated from your schema. Regenerate if the schema " \
|
|
577
|
+
"dump or a registration moved (rake graph_weaver:generate), or re-record if the server's " \
|
|
578
|
+
"answer did (GRAPHWEAVER_RECORD=1, with a live client:)."
|
|
288
579
|
end
|
|
289
580
|
if checks.sum(&:checked).zero?
|
|
290
581
|
# a green run that compared nothing is worse than a failure: it would
|
|
@@ -298,7 +589,7 @@ namespace :graph_weaver do
|
|
|
298
589
|
end
|
|
299
590
|
|
|
300
591
|
desc "Anonymize every cassette in Testing.config.cassette_dir (PII-safe to commit)"
|
|
301
|
-
task anonymize: :
|
|
592
|
+
task anonymize: :own_schema do
|
|
302
593
|
require "graph_weaver/testing"
|
|
303
594
|
|
|
304
595
|
# locate, not schema_path: the dump is whichever supported extension is
|