graph_weaver 0.7.4 → 0.7.6
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.lock +2 -2
- data/README.md +1 -0
- data/docs/errors.md +12 -5
- data/docs/generated_modules.md +134 -17
- data/docs/getting_started.md +182 -20
- data/docs/logging.md +79 -35
- data/docs/migrating.md +126 -0
- data/docs/scalars.md +50 -6
- data/docs/testing.md +78 -14
- data/docs/upgrading.md +44 -2
- data/examples/README.md +4 -2
- data/examples/github/generate.rb +22 -8
- data/examples/github/generated/star_mutation.rb +2 -2
- data/examples/github/generated/stargazers_query.rb +2 -2
- data/examples/github/generated/starred_query.rb +2 -2
- data/examples/github/run.rb +1 -0
- data/examples/github/setup.rb +16 -8
- data/graph_weaver.gemspec +15 -6
- data/lib/generators/graph_weaver/install_generator.rb +49 -2
- data/lib/graph_weaver/client.rb +0 -23
- data/lib/graph_weaver/codegen/aliases.rb +36 -3
- data/lib/graph_weaver/codegen/emit.rb +20 -15
- data/lib/graph_weaver/codegen/enum_type.rb +52 -11
- data/lib/graph_weaver/codegen/nodes.rb +75 -32
- data/lib/graph_weaver/codegen.rb +260 -100
- data/lib/graph_weaver/coerce.rb +1 -1
- data/lib/graph_weaver/federation.rb +1 -6
- data/lib/graph_weaver/graph.rb +55 -5
- data/lib/graph_weaver/hints.rb +20 -5
- data/lib/graph_weaver/in_process.rb +2 -4
- data/lib/graph_weaver/input_struct.rb +50 -10
- data/lib/graph_weaver/internal/overrides.rb +126 -14
- data/lib/graph_weaver/internal/subgraphs.rb +1 -10
- data/lib/graph_weaver/internal/test_clients.rb +29 -7
- data/lib/graph_weaver/internal/unused.rb +62 -18
- data/lib/graph_weaver/internal/values.rb +24 -7
- data/lib/graph_weaver/internal.rb +23 -6
- data/lib/graph_weaver/log_subscriber.rb +27 -17
- data/lib/graph_weaver/logging.rb +115 -82
- data/lib/graph_weaver/parsing.rb +32 -3
- data/lib/graph_weaver/query_module.rb +67 -12
- data/lib/graph_weaver/railtie.rb +7 -2
- data/lib/graph_weaver/rspec.rb +41 -18
- data/lib/graph_weaver/schema_diff.rb +24 -5
- data/lib/graph_weaver/schema_loader.rb +29 -17
- data/lib/graph_weaver/tasks.rb +98 -16
- data/lib/graph_weaver/testing/fake_client.rb +28 -31
- data/lib/graph_weaver/testing/router.rb +26 -25
- data/lib/graph_weaver/testing.rb +27 -8
- data/lib/graph_weaver/transport.rb +1 -1
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +72 -42
- metadata +3 -2
data/lib/graph_weaver/rspec.rb
CHANGED
|
@@ -161,7 +161,9 @@ module GraphWeaver
|
|
|
161
161
|
# suite's WebMock setup is touched.
|
|
162
162
|
def self.serve!
|
|
163
163
|
webmock!
|
|
164
|
-
|
|
164
|
+
targets, above = wire_targets
|
|
165
|
+
above.each { |graph| disclose_above!(graph) }
|
|
166
|
+
targets.map do |url, graph|
|
|
165
167
|
# built here, so a graph with nothing to serve refuses before the
|
|
166
168
|
# example runs rather than from inside its first request
|
|
167
169
|
disclose!(GraphWeaver::Internal::TestClients.standin(graph), url, graph)
|
|
@@ -210,6 +212,17 @@ module GraphWeaver
|
|
|
210
212
|
end
|
|
211
213
|
end
|
|
212
214
|
|
|
215
|
+
# Say which graph ran above the wire. Its stand-in is built when one of
|
|
216
|
+
# its modules first runs, not here — a graph the example never touches
|
|
217
|
+
# must not refuse it — so this names the graph rather than what is
|
|
218
|
+
# behind it.
|
|
219
|
+
def self.disclose_above!(graph)
|
|
220
|
+
GraphWeaver::Internal::Log.log(:info) do
|
|
221
|
+
":wire has no endpoint for #{graph.name ? "graph #{graph.name.inspect}" : "this app"} — " \
|
|
222
|
+
"its client posts to none, so its modules run above the wire, as #{TAG}: :in_process would"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
213
226
|
# What the stand-in IS, read off the object rather than re-deciding —
|
|
214
227
|
# one answer, and it can't drift from what was built. A fake of a dump
|
|
215
228
|
# has no name to give: the dump loads as an anonymous class, and
|
|
@@ -254,21 +267,24 @@ module GraphWeaver
|
|
|
254
267
|
# inside the cleanup.
|
|
255
268
|
def self.unserve!(stub) = WebMock::StubRegistry.instance.request_stubs.delete(stub)
|
|
256
269
|
|
|
257
|
-
#
|
|
258
|
-
# whose resolvers belong behind it
|
|
259
|
-
#
|
|
260
|
-
#
|
|
270
|
+
# What :wire does with each graph, in two lists: the endpoints to stub,
|
|
271
|
+
# each with the graph whose resolvers belong behind it, and the graphs
|
|
272
|
+
# there is no endpoint for. One graph per endpoint — an app whose graphs
|
|
273
|
+
# all name clients needs no app default at all.
|
|
274
|
+
#
|
|
275
|
+
# A graph whose client posts to no url has no wire to be served at, so
|
|
276
|
+
# it runs above one instead of refusing the example — including the
|
|
277
|
+
# examples that never touch it. An example where NO graph posts anywhere
|
|
278
|
+
# is refused, since a :wire that serves nothing tests no transport.
|
|
261
279
|
def self.wire_targets
|
|
262
|
-
targets = GraphWeaver.graphs.
|
|
263
|
-
client = graph.client || GraphWeaver.client
|
|
264
|
-
[endpoint!(client, graph), graph] if client
|
|
265
|
-
end
|
|
280
|
+
targets, above = GraphWeaver.graphs.map { |graph| [graph.client_url, graph] }.partition(&:first)
|
|
266
281
|
refuse_shared_endpoint!(targets)
|
|
267
|
-
return targets if targets.any?
|
|
282
|
+
return [targets, above.map(&:last)] if targets.any?
|
|
268
283
|
|
|
269
|
-
#
|
|
270
|
-
#
|
|
271
|
-
|
|
284
|
+
# the endpoint refusal names the client that posts to none, which is
|
|
285
|
+
# the thing to fix
|
|
286
|
+
graph = GraphWeaver.graphs.first
|
|
287
|
+
endpoint!(graph&.client || GraphWeaver.client, graph)
|
|
272
288
|
end
|
|
273
289
|
|
|
274
290
|
# One stub per url, so two graphs on one endpoint used to mean the
|
|
@@ -343,7 +359,8 @@ module GraphWeaver
|
|
|
343
359
|
end
|
|
344
360
|
|
|
345
361
|
private_class_method :wire_targets, :refuse_shared_endpoint!, :whose_client,
|
|
346
|
-
:webmock!, :webmock_enabled?, :disclose!, :served, :unnamed_schemas,
|
|
362
|
+
:webmock!, :webmock_enabled?, :disclose!, :disclose_above!, :served, :unnamed_schemas,
|
|
363
|
+
:loaded_schemas
|
|
347
364
|
|
|
348
365
|
# Included into every example group, so graphql_context is there
|
|
349
366
|
# whether or not this example took a client from the hook.
|
|
@@ -570,10 +587,17 @@ module GraphWeaver
|
|
|
570
587
|
raise GraphWeaver::Error, "graphql_context needs resolvers to receive it, and a " \
|
|
571
588
|
"#{TAG}: :fake example runs against fabricated data — tag it #{TAG}: :in_process or " \
|
|
572
589
|
"#{TAG}: :router (or pin the data itself: " \
|
|
573
|
-
"graphql_fake(
|
|
590
|
+
"graphql_fake(\"Person.name\" => \"Ada\"))"
|
|
574
591
|
else
|
|
575
|
-
|
|
576
|
-
|
|
592
|
+
# :live, which is what an untagged example is. The app's own client
|
|
593
|
+
# stays in the slot holding the context it was built with, so there
|
|
594
|
+
# is nothing here to merge onto — and saying "you need resolvers"
|
|
595
|
+
# reads as false to an app whose own client is an InProcess, which
|
|
596
|
+
# is running them
|
|
597
|
+
raise GraphWeaver::Error, "graphql_context says what THIS example's resolvers see, " \
|
|
598
|
+
"and #{TAG}: :live leaves your app's own client exactly as it is — carrying the " \
|
|
599
|
+
"context it was built with. Tag the example #{TAG}: :in_process or #{TAG}: :router, " \
|
|
600
|
+
"which build a client per example."
|
|
577
601
|
end
|
|
578
602
|
end
|
|
579
603
|
|
|
@@ -592,7 +616,6 @@ module GraphWeaver
|
|
|
592
616
|
"may itself be a proc, so it can vary per request."
|
|
593
617
|
end
|
|
594
618
|
private_class_method :wire_context!
|
|
595
|
-
|
|
596
619
|
end
|
|
597
620
|
end
|
|
598
621
|
end
|
|
@@ -122,6 +122,8 @@ module GraphWeaver
|
|
|
122
122
|
return change(name, "#{kind(old)} -> #{kind(new)}", breaking: true)
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
+
compare_description(name, old, new)
|
|
126
|
+
|
|
125
127
|
case new.kind.name
|
|
126
128
|
when "OBJECT", "INTERFACE"
|
|
127
129
|
compare_fields(name, old, new)
|
|
@@ -151,6 +153,7 @@ module GraphWeaver
|
|
|
151
153
|
breaking: breaks_output?(old.type, new.type))
|
|
152
154
|
end
|
|
153
155
|
compare_deprecation(coordinate, old, new)
|
|
156
|
+
compare_description(coordinate, old, new)
|
|
154
157
|
compare_arguments(coordinate, old.arguments, new.arguments)
|
|
155
158
|
end
|
|
156
159
|
|
|
@@ -192,6 +195,7 @@ module GraphWeaver
|
|
|
192
195
|
change(coordinate, "#{prefix}#{signature(old)} -> #{signature(new)}", breaking:)
|
|
193
196
|
end
|
|
194
197
|
compare_deprecation(coordinate, old, new)
|
|
198
|
+
compare_description(coordinate, old, new)
|
|
195
199
|
end
|
|
196
200
|
|
|
197
201
|
def compare_enum(name, old, new)
|
|
@@ -200,7 +204,10 @@ module GraphWeaver
|
|
|
200
204
|
|
|
201
205
|
(before.keys - after.keys).each { |value| change("#{name}.#{value}", "enum value removed", breaking: true) }
|
|
202
206
|
(after.keys - before.keys).each { |value| change("#{name}.#{value}", "enum value added") }
|
|
203
|
-
(before.keys & after.keys).each
|
|
207
|
+
(before.keys & after.keys).each do |value|
|
|
208
|
+
compare_deprecation("#{name}.#{value}", before[value], after[value])
|
|
209
|
+
compare_description("#{name}.#{value}", before[value], after[value])
|
|
210
|
+
end
|
|
204
211
|
end
|
|
205
212
|
|
|
206
213
|
# A dropped member silently stops matching a `... on X` fragment, which
|
|
@@ -233,10 +240,22 @@ module GraphWeaver
|
|
|
233
240
|
change(coordinate, now ? "deprecated: #{now}" : "no longer deprecated")
|
|
234
241
|
end
|
|
235
242
|
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
243
|
+
# A description breaks nothing and reaches no generated code, but it is
|
|
244
|
+
# the likeliest thing to differ between a hand-maintained dump and the
|
|
245
|
+
# server — and unnamed it fell through to note_unnamed_drift, which is a
|
|
246
|
+
# permanent red with nothing to act on. The text itself isn't printed: it
|
|
247
|
+
# can be paragraphs, and the coordinate is what you go and look at.
|
|
248
|
+
def compare_description(coordinate, old, new)
|
|
249
|
+
return unless old.respond_to?(:description)
|
|
250
|
+
return if old.description == new.description
|
|
251
|
+
|
|
252
|
+
change(coordinate, "description changed")
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# The walk names what a client breaks on. A directive definition or an
|
|
256
|
+
# argument default moves the SDL without appearing above — still drift,
|
|
257
|
+
# and a gate that went green on it would be worse than one that admits
|
|
258
|
+
# it can't name it.
|
|
240
259
|
def note_unnamed_drift(before, after)
|
|
241
260
|
return unless @changes.empty?
|
|
242
261
|
return if before.to_definition == after.to_definition
|
|
@@ -199,10 +199,21 @@ module GraphWeaver::SchemaLoader
|
|
|
199
199
|
COMPOSITION_SPEC = %r{@(?:link\s*\(\s*url|core\s*\(\s*feature):\s*"https://specs\.apollo\.dev/(?:join|core)/}
|
|
200
200
|
private_constant :COMPOSITION_SPEC
|
|
201
201
|
|
|
202
|
-
# A composed Fed2 supergraph is marked by @join__* directives
|
|
203
|
-
# type carries them); a plain schema
|
|
202
|
+
# A composed Fed2 supergraph is marked by @join__* directives APPLIED to
|
|
203
|
+
# its types (every merged type carries them); a plain schema applies none.
|
|
204
|
+
#
|
|
205
|
+
# Applied, not merely declared: introspect a router and graphql-ruby hands
|
|
206
|
+
# back the API schema with the @join__* directive DEFINITIONS still in it
|
|
207
|
+
# and every application gone — so a bare substring test answers "composed"
|
|
208
|
+
# about the one artifact whose routing table has been removed.
|
|
209
|
+
JOIN_DEFINITION = /\bdirective\s+@join__\w+/
|
|
210
|
+
private_constant :JOIN_DEFINITION
|
|
211
|
+
|
|
204
212
|
def self.federation_sdl?(sdl)
|
|
205
|
-
|
|
213
|
+
return true if sdl.match?(COMPOSITION_SPEC)
|
|
214
|
+
return false unless sdl.match?(/@join__\w/)
|
|
215
|
+
|
|
216
|
+
sdl.gsub(JOIN_DEFINITION, "").match?(/@join__\w/)
|
|
206
217
|
end
|
|
207
218
|
|
|
208
219
|
# The federation spec a fed-2 subgraph @links, and the directives a fed-1
|
|
@@ -720,12 +731,13 @@ module GraphWeaver::SchemaLoader
|
|
|
720
731
|
schema = build_introspection(result)
|
|
721
732
|
|
|
722
733
|
if cache
|
|
723
|
-
# the extension picks the format: .json is the
|
|
724
|
-
#
|
|
725
|
-
#
|
|
734
|
+
# the extension picks the format: .json is the wire artifact, pretty-
|
|
735
|
+
# printed so a refresh diffs line by line (graphql-client's dump is
|
|
736
|
+
# too); .graphql/.gql is SDL — human-readable, PR-reviewable diffs
|
|
737
|
+
# (both generate byte-identical code)
|
|
726
738
|
meta = stamp(transport, auth_env)
|
|
727
739
|
content = if cache.end_with?(".json")
|
|
728
|
-
JSON.
|
|
740
|
+
JSON.pretty_generate(meta ? result.merge("graph_weaver" => meta) : result)
|
|
729
741
|
else
|
|
730
742
|
header = meta && "# graph_weaver: #{JSON.generate(meta)}\n\n"
|
|
731
743
|
"#{header}#{schema.to_definition}"
|
|
@@ -887,9 +899,10 @@ module GraphWeaver::SchemaLoader
|
|
|
887
899
|
return recompose_hint(path) if composed_dump?(path)
|
|
888
900
|
|
|
889
901
|
missing = path ? "#{path} records no source url" : "no schema dump at #{GraphWeaver.schema_path}"
|
|
890
|
-
"#{missing} — pass one: rake graph_weaver:schema:refresh
|
|
891
|
-
"
|
|
892
|
-
"
|
|
902
|
+
"#{missing} — pass one: rake graph_weaver:schema:refresh " \
|
|
903
|
+
"URL=https://api.example.com/graphql, or point the graph's client at the server — an app " \
|
|
904
|
+
"that serves the schema itself points GraphWeaver.client at the class and the dump is built " \
|
|
905
|
+
"from that (docs/getting_started.md#your-apps-own-schema-in-process)"
|
|
893
906
|
end
|
|
894
907
|
private_class_method :refresh_hint
|
|
895
908
|
|
|
@@ -908,13 +921,14 @@ module GraphWeaver::SchemaLoader
|
|
|
908
921
|
# Composition is the only thing that rebuilds a supergraph: introspection
|
|
909
922
|
# answers with the API schema, which is the merged shape minus the routing
|
|
910
923
|
# table, so refreshing one from a url replaces the contract with a strictly
|
|
911
|
-
# smaller artifact and reports success.
|
|
924
|
+
# smaller artifact and reports success. Public so `schema:refresh` can step
|
|
925
|
+
# over such a graph in these words rather than reaching the overwrite and
|
|
926
|
+
# relying on the guard to catch it.
|
|
912
927
|
def self.recompose_hint(path)
|
|
913
928
|
"#{GraphWeaver::Internal::Util.relative(path)} is a composed supergraph; introspection returns " \
|
|
914
929
|
"the API schema, not the @join__* routing table — recompose it (rover supergraph compose) " \
|
|
915
930
|
"and check the result in, instead of refreshing it"
|
|
916
931
|
end
|
|
917
|
-
private_class_method :recompose_hint
|
|
918
932
|
|
|
919
933
|
# A transport to the dump's recorded url, authenticated from whichever ENV
|
|
920
934
|
# var the dump named (else DEFAULT_AUTH_ENV). The single way to reach a
|
|
@@ -922,11 +936,9 @@ module GraphWeaver::SchemaLoader
|
|
|
922
936
|
# ends up honoured in some places and not others.
|
|
923
937
|
def self.source_transport(path)
|
|
924
938
|
meta = provenance(path)
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
"or rebuild it from the schema class that produced it."
|
|
929
|
-
end
|
|
939
|
+
# the same sentence refresh! gives: this is reached by typing a rake task
|
|
940
|
+
# (schema:diff), and `transport:` is not something a rake user can pass
|
|
941
|
+
raise GraphWeaver::Error, refresh_hint(path) unless meta&.key?("url")
|
|
930
942
|
|
|
931
943
|
GraphWeaver.new(meta["url"], auth: ENV[auth_env(path)]).transport
|
|
932
944
|
end
|
data/lib/graph_weaver/tasks.rb
CHANGED
|
@@ -170,6 +170,27 @@ module GraphWeaver
|
|
|
170
170
|
}.filter_map { |kind, names| " #{kind}: #{names.sort.join(", ")}" if names.any? }
|
|
171
171
|
end
|
|
172
172
|
|
|
173
|
+
# What `queries:check` just answered. It re-introspects the source behind
|
|
174
|
+
# a dump and asks a live class directly; a graph with neither it checks
|
|
175
|
+
# as it stands on disk, which is `verify`'s question — and both exited 0
|
|
176
|
+
# saying "against the schema".
|
|
177
|
+
def self.validated
|
|
178
|
+
dumps = GraphWeaver.graphs.filter_map { |graph| as_committed(graph) }
|
|
179
|
+
return "every query validates against the schema" if dumps.empty?
|
|
180
|
+
|
|
181
|
+
"every query validates against #{dumps.join(", ")} as committed — not the server " \
|
|
182
|
+
"(rake graph_weaver:schema:diff asks whether the server moved)"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# A graph whose queries were checked against the file rather than the
|
|
186
|
+
# server: nothing behind the dump to re-read, which is what a nil
|
|
187
|
+
# Graph#source says.
|
|
188
|
+
def self.as_committed(graph)
|
|
189
|
+
path = graph.dump_path
|
|
190
|
+
GraphWeaver::Internal::Util.relative(path) if path && graph.source.nil?
|
|
191
|
+
end
|
|
192
|
+
private_class_method :as_committed
|
|
193
|
+
|
|
173
194
|
# Neither task that needs the committed dump can take one itself, so both
|
|
174
195
|
# say which task can — the same sentence SchemaLoader gives on refresh.
|
|
175
196
|
def self.no_dump
|
|
@@ -184,7 +205,25 @@ module GraphWeaver
|
|
|
184
205
|
# rewrites it from the graph's source, `diff` says how far that source
|
|
185
206
|
# has drifted from it, whichever the source is. A graph whose schema IS
|
|
186
207
|
# a live class reads no dump at all, so it has neither.
|
|
187
|
-
|
|
208
|
+
#
|
|
209
|
+
# The dump's own record of where it came from first, then the client the
|
|
210
|
+
# graph's modules already call: a dump inherited with no provenance, or a
|
|
211
|
+
# schema kept by hand, still has a server behind it, and refusing one
|
|
212
|
+
# took every other graph's refresh down with it.
|
|
213
|
+
def self.dumps
|
|
214
|
+
GraphWeaver.graphs.map { |graph| [graph, graph.dump_path, graph.source] }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# A dump with no recorded url whose graph names no server: nothing
|
|
218
|
+
# behind the file to re-read, so the file is the schema. One shape, one
|
|
219
|
+
# diagnosis — :refresh steps over it and :diff can't assert anything
|
|
220
|
+
# about it, and they used to describe it in two different sentences and
|
|
221
|
+
# disagree about whose whole run it ended.
|
|
222
|
+
def self.no_source(path)
|
|
223
|
+
"#{GraphWeaver::Internal::Util.relative(path)} records no source url and the graph names " \
|
|
224
|
+
"no server behind it — no client posting to one, and no graphql-ruby schema class in " \
|
|
225
|
+
"this process — so the file is the schema and nothing here can re-read it"
|
|
226
|
+
end
|
|
188
227
|
|
|
189
228
|
# A graph that generates straight from a schema class has no dump
|
|
190
229
|
# between the code and the output — so there is nothing here to
|
|
@@ -340,19 +379,31 @@ namespace :graph_weaver do
|
|
|
340
379
|
|
|
341
380
|
desc "Fail when the schema behind the dump has drifted from it"
|
|
342
381
|
task diff: :own_schema do
|
|
343
|
-
|
|
344
|
-
abort
|
|
345
|
-
|
|
346
|
-
|
|
382
|
+
# One rule per graph, and no graph's verdict depends on what the others
|
|
383
|
+
# have: the "no dump anywhere" abort used to run before the loop, so a
|
|
384
|
+
# lone live-class graph was refused naming a path it never mentions
|
|
385
|
+
# while the same graph passed as soon as a sibling had a dump.
|
|
386
|
+
ungated = []
|
|
387
|
+
stale = GraphWeaver::Internal::Tasks.dumps.filter_map do |graph, path, source|
|
|
347
388
|
heading = GraphWeaver::Internal::Tasks.heading(graph)
|
|
348
389
|
puts heading if heading
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
390
|
+
unless path
|
|
391
|
+
# a graph that names a live class generates straight from it: no
|
|
392
|
+
# dump between the code and the output, so nothing can be stale.
|
|
393
|
+
# One whose dump is merely missing has something this gate can't see.
|
|
394
|
+
puts GraphWeaver::Internal::Tasks.no_dump_needed(graph, source)
|
|
395
|
+
ungated << graph if graph.named_dump_path || !source
|
|
396
|
+
next
|
|
397
|
+
end
|
|
398
|
+
unless source
|
|
399
|
+
# the shape :refresh steps over, in :refresh's own words — this task
|
|
400
|
+
# still fails on it, because a gate cannot assert nothing
|
|
401
|
+
puts GraphWeaver::Internal::Tasks.no_source(path)
|
|
402
|
+
ungated << graph
|
|
403
|
+
next
|
|
404
|
+
end
|
|
352
405
|
|
|
353
|
-
|
|
354
|
-
# the dump's own transport, auth and all
|
|
355
|
-
diff = GraphWeaver::SchemaLoader.diff(path, transport: (source if source.is_a?(Module)))
|
|
406
|
+
diff = GraphWeaver::SchemaLoader.diff(path, transport: graph.source_transport)
|
|
356
407
|
dump = GraphWeaver::Internal::Util.relative(path)
|
|
357
408
|
next puts "#{dump} matches #{GraphWeaver::Internal::Tasks.source_name(source)}" if diff.empty?
|
|
358
409
|
|
|
@@ -367,6 +418,12 @@ namespace :graph_weaver do
|
|
|
367
418
|
abort "#{stale.join(", ")} is stale — the schema behind it has drifted " \
|
|
368
419
|
"(rake graph_weaver:schema:refresh)"
|
|
369
420
|
end
|
|
421
|
+
# a gate that passes on having compared nothing is worse than one that
|
|
422
|
+
# admits it: the sentence above says which graph and why
|
|
423
|
+
unless ungated.empty?
|
|
424
|
+
abort "#{GraphWeaver::Internal::Tasks.whose(ungated)}nothing here could be compared — " \
|
|
425
|
+
"this run gated nothing"
|
|
426
|
+
end
|
|
370
427
|
rescue GraphWeaver::Error => e
|
|
371
428
|
# e.g. a dump that is its own source — same clean exit as :refresh
|
|
372
429
|
abort e.message
|
|
@@ -387,7 +444,10 @@ namespace :graph_weaver do
|
|
|
387
444
|
next puts "refreshed #{GraphWeaver::Internal::Util.relative(path)} from #{source}"
|
|
388
445
|
end
|
|
389
446
|
|
|
390
|
-
|
|
447
|
+
# Every graph is its own job: one unreachable server used to end the run
|
|
448
|
+
# with the graphs after it never attempted, so an app learned about them
|
|
449
|
+
# one per run. The exit code is the only thing they share.
|
|
450
|
+
failed = GraphWeaver::Internal::Tasks.dumps.filter_map do |graph, path, source|
|
|
391
451
|
heading = GraphWeaver::Internal::Tasks.heading(graph)
|
|
392
452
|
puts heading if heading
|
|
393
453
|
# a graph that names a live class generates straight from it — no
|
|
@@ -398,10 +458,28 @@ namespace :graph_weaver do
|
|
|
398
458
|
# below and refresh! bootstraps its first dump (or says how).
|
|
399
459
|
path ||= graph.named_dump_path
|
|
400
460
|
next puts GraphWeaver::Internal::Tasks.no_dump_needed(graph, source) if !path && graph.named_schema?
|
|
461
|
+
# composition is the only thing that rebuilds a supergraph, so this
|
|
462
|
+
# graph is one to step over however good its client is — introspecting
|
|
463
|
+
# the router answers with the API schema, which routes nothing
|
|
464
|
+
next puts GraphWeaver::SchemaLoader.recompose_hint(path) if graph.supergraph
|
|
465
|
+
next puts GraphWeaver::Internal::Tasks.no_source(path) if path && !source
|
|
466
|
+
|
|
467
|
+
begin
|
|
468
|
+
written, from = GraphWeaver::SchemaLoader.refresh!(url: (source unless source.is_a?(Module)),
|
|
469
|
+
schema: (source if source.is_a?(Module)), path:)
|
|
470
|
+
puts "refreshed #{GraphWeaver::Internal::Util.relative(written)} from #{from}"
|
|
471
|
+
nil
|
|
472
|
+
rescue GraphWeaver::Error => e
|
|
473
|
+
puts e.message
|
|
474
|
+
graph
|
|
475
|
+
end
|
|
476
|
+
end
|
|
401
477
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
478
|
+
# abort writes to unbuffered stderr; the reports above went to
|
|
479
|
+
# block-buffered stdout, so a piped CI log shows them first
|
|
480
|
+
$stdout.flush
|
|
481
|
+
unless failed.empty?
|
|
482
|
+
abort "#{GraphWeaver::Internal::Tasks.whose(failed)}not refreshed — the dump on disk is unchanged"
|
|
405
483
|
end
|
|
406
484
|
rescue GraphWeaver::Error => e
|
|
407
485
|
abort e.message
|
|
@@ -425,7 +503,11 @@ namespace :graph_weaver do
|
|
|
425
503
|
# block-buffered stdout, so a piped CI log shows the verdict first
|
|
426
504
|
$stdout.flush
|
|
427
505
|
abort "#{failures.size} invalid #{(failures.size == 1) ? "query" : "queries"}" if failures.any?
|
|
428
|
-
puts
|
|
506
|
+
puts GraphWeaver::Internal::Tasks.validated
|
|
507
|
+
rescue GraphWeaver::Error => e
|
|
508
|
+
# this task reaches a server now, so an unreachable one is a condition to
|
|
509
|
+
# report — the same clean exit :diff and :refresh give
|
|
510
|
+
abort e.message
|
|
429
511
|
end
|
|
430
512
|
end
|
|
431
513
|
|
|
@@ -86,12 +86,14 @@ require_relative "../parsing"
|
|
|
86
86
|
#
|
|
87
87
|
# FakeClient.new(schema:, corrupt: "Person.birthday")
|
|
88
88
|
#
|
|
89
|
-
# null_chance: how often a nullable field comes back null —
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
# example
|
|
89
|
+
# null_chance: how often a nullable field comes back null — a number from 0
|
|
90
|
+
# to 1 for all of them, or a Hash per field keyed the way list_size: is. 0
|
|
91
|
+
# by default, and per fake only: "does this render with no email" is one
|
|
92
|
+
# example's question, and a suite-wide answer would sprinkle nils through
|
|
93
|
+
# every other example instead.
|
|
93
94
|
#
|
|
94
95
|
# FakeClient.new(schema:, null_chance: 1.0) # everything nullable, null
|
|
96
|
+
# FakeClient.new(schema:, null_chance: { "Person.nickname" => 1.0, default: 0 })
|
|
95
97
|
#
|
|
96
98
|
# list_size: how long an unbounded list is — an Integer exactly, a Range
|
|
97
99
|
# randomized within it, and a Hash per list, keyed the way a pin is (a
|
|
@@ -137,12 +139,6 @@ class GraphWeaver::Testing::FakeClient
|
|
|
137
139
|
null_chance: nil, errors: nil, fail_at: nil, corrupt: nil,
|
|
138
140
|
}.freeze
|
|
139
141
|
|
|
140
|
-
# JSON's own types are already on the wire: at a leaf they skip the
|
|
141
|
-
# registry's serializer (Values#wire), and at a composite position (a Hash
|
|
142
|
-
# aside, which is response keys) they pin the field as written — nil is
|
|
143
|
-
# null, the rest is the corrupt payload the example asked for.
|
|
144
|
-
WIRE = GraphWeaver::Internal::Values::WIRE
|
|
145
|
-
|
|
146
142
|
# Methods every Ruby object answers aren't fields: a schema does have a
|
|
147
143
|
# `hash` or a `count`, and a Struct answers both with plausible nonsense
|
|
148
144
|
# where fabricating is right.
|
|
@@ -151,7 +147,7 @@ class GraphWeaver::Testing::FakeClient
|
|
|
151
147
|
# The scalars the GraphQL spec serializes as JSON strings, whatever Ruby
|
|
152
148
|
# holds them.
|
|
153
149
|
STRING_SCALARS = %w[ID String].freeze
|
|
154
|
-
private_constant :OPTIONS, :
|
|
150
|
+
private_constant :OPTIONS, :RUBY_OWN, :STRING_SCALARS
|
|
155
151
|
|
|
156
152
|
def initialize(pins = {}, **options)
|
|
157
153
|
config = GraphWeaver::Testing.config
|
|
@@ -173,10 +169,10 @@ class GraphWeaver::Testing::FakeClient
|
|
|
173
169
|
@registry = options[:registry] || GraphWeaver::Internal::Util.registry_for(@schema)
|
|
174
170
|
@values = GraphWeaver::Internal::Values.new(seed: options[:seed], values: options[:values],
|
|
175
171
|
pins: @overrides, schema: @schema, registry: @registry)
|
|
176
|
-
@list_size = options[:list_size] || config.list_size
|
|
177
|
-
@list_size = @list_size.transform_keys(&:to_s) if @list_size.is_a?(Hash)
|
|
172
|
+
@list_size = stringify(options[:list_size] || config.list_size)
|
|
178
173
|
GraphWeaver::Internal::Overrides.validate_list_size!(@schema, @list_size)
|
|
179
|
-
@null_chance = options[:null_chance] || 0.0
|
|
174
|
+
@null_chance = stringify(options[:null_chance] || 0.0)
|
|
175
|
+
GraphWeaver::Internal::Overrides.validate_null_chance!(@schema, @null_chance)
|
|
180
176
|
# NOT Array(): it would explode a bare Hash into key/value pairs
|
|
181
177
|
@extra_errors = wrap(options[:errors]).map { |error| normalize_error(error) }
|
|
182
178
|
@fail_at = wrap(options[:fail_at]).map { |spec| normalize_fail_spec(spec) }
|
|
@@ -467,7 +463,9 @@ class GraphWeaver::Testing::FakeClient
|
|
|
467
463
|
# reads them off — a FactoryBot build, a model, a Struct. Either way it
|
|
468
464
|
# MERGES: what it doesn't answer is fabricated.
|
|
469
465
|
def pinned_object(type, selections, value, source)
|
|
470
|
-
|
|
466
|
+
# what JSON already holds is the pin as written — nil is null, and the rest
|
|
467
|
+
# is the corrupt payload the example asked for (a Hash is response keys)
|
|
468
|
+
return value if !value.is_a?(Hash) && GraphWeaver::Internal::Values.wire?(value)
|
|
471
469
|
|
|
472
470
|
concrete = pinned_type(type, value, source)
|
|
473
471
|
pins = value.is_a?(Hash) ? value : read_fields(concrete, selections, value)
|
|
@@ -497,8 +495,6 @@ class GraphWeaver::Testing::FakeClient
|
|
|
497
495
|
object.respond_to?(name) && !RUBY_OWN.include?(object.method(name).owner)
|
|
498
496
|
end
|
|
499
497
|
|
|
500
|
-
def wire?(value) = WIRE.any? { |klass| value.is_a?(klass) }
|
|
501
|
-
|
|
502
498
|
# An object pin holds Ruby values — a Time, a Money, a T::Enum — where the
|
|
503
499
|
# wire holds what the registration says they serialize to. A value that
|
|
504
500
|
# is already wire-shaped is taken as written.
|
|
@@ -618,7 +614,7 @@ class GraphWeaver::Testing::FakeClient
|
|
|
618
614
|
return [capped, 0].max if capped.is_a?(Integer)
|
|
619
615
|
return 0 if errors_list?(node.name)
|
|
620
616
|
|
|
621
|
-
size =
|
|
617
|
+
size = per_field(@list_size, coordinate, node.name, GraphWeaver::Testing::Config::DEFAULT_LIST_SIZE)
|
|
622
618
|
# an Integer list_size means exactly that many; a Range randomizes within it
|
|
623
619
|
size.is_a?(Range) ? rng.rand(size) : size
|
|
624
620
|
end
|
|
@@ -630,28 +626,29 @@ class GraphWeaver::Testing::FakeClient
|
|
|
630
626
|
# until it is pinned. Pin it to fabricate the failure path.
|
|
631
627
|
def errors_list?(name) = name.downcase.end_with?("errors")
|
|
632
628
|
|
|
633
|
-
#
|
|
634
|
-
# specific first like a pin — which is what keeps
|
|
635
|
-
# multiplying: every list the walk reaches re-reads
|
|
636
|
-
# for all of them is n rows x n tags.
|
|
637
|
-
def
|
|
638
|
-
return
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
@list_size.fetch(GraphWeaver::Internal::Overrides::LIST_SIZE_DEFAULT,
|
|
643
|
-
GraphWeaver::Testing::Config::DEFAULT_LIST_SIZE)
|
|
644
|
-
end
|
|
629
|
+
# What a per-field option (list_size:, null_chance:) says here. A Hash says
|
|
630
|
+
# it per field, read most specific first like a pin — which is what keeps
|
|
631
|
+
# nested lists from multiplying: every list the walk reaches re-reads
|
|
632
|
+
# list_size, so one number for all of them is n rows x n tags.
|
|
633
|
+
def per_field(option, coordinate, name, fallback)
|
|
634
|
+
return option unless option.is_a?(Hash)
|
|
635
|
+
|
|
636
|
+
option.fetch(coordinate) do
|
|
637
|
+
option.fetch(name) { option.fetch(GraphWeaver::Internal::Overrides::DEFAULT_KEY, fallback) }
|
|
645
638
|
end
|
|
646
639
|
end
|
|
647
640
|
|
|
641
|
+
# A per-field option's keys are GraphQL names, written as either a String
|
|
642
|
+
# or a Symbol (`default:` most of all).
|
|
643
|
+
def stringify(option) = option.is_a?(Hash) ? option.transform_keys(&:to_s) : option
|
|
644
|
+
|
|
648
645
|
def type_value(type, node, selections, coordinate: nil, non_null: false)
|
|
649
646
|
if type.kind.name == "NON_NULL"
|
|
650
647
|
return type_value(type.of_type, node, selections, coordinate:, non_null: true)
|
|
651
648
|
end
|
|
652
649
|
# every nullable position, a list included — null_chance is about the
|
|
653
650
|
# nilable props codegen emitted, and it emits one for `[Thing!]` too
|
|
654
|
-
return if !non_null && rng.rand < @null_chance
|
|
651
|
+
return if !non_null && rng.rand < per_field(@null_chance, coordinate, node.name, 0.0)
|
|
655
652
|
|
|
656
653
|
case type.kind.name
|
|
657
654
|
when "LIST"
|