graph_weaver 0.4.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1357 -0
- data/CLAUDE.md +100 -8
- data/DECISIONS.md +309 -0
- data/Gemfile.lock +23 -23
- data/NOTES.md +5 -5
- data/PLAN.md +106 -135
- data/README.md +115 -96
- data/REVIEW.md +946 -0
- data/docs/cassettes.md +75 -48
- data/docs/editors.md +82 -0
- data/docs/errors.md +32 -30
- data/docs/federation.md +520 -48
- data/docs/generated_modules.md +352 -137
- data/docs/getting_started.md +237 -67
- data/docs/logging.md +35 -6
- data/docs/real_world.md +21 -15
- data/docs/scalars.md +49 -136
- data/docs/testing.md +299 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +112 -0
- data/graph_weaver.gemspec +3 -1
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +114 -111
- data/lib/graph_weaver/codegen/aliases.rb +217 -0
- data/lib/graph_weaver/codegen/emit.rb +272 -251
- data/lib/graph_weaver/codegen/enum_type.rb +27 -98
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +617 -264
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +12 -6
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +21 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +67 -0
- data/lib/graph_weaver/query_module.rb +55 -0
- data/lib/graph_weaver/railtie.rb +23 -1
- data/lib/graph_weaver/representation.rb +74 -0
- data/lib/graph_weaver/response.rb +15 -1
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +820 -57
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +59 -7
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +186 -62
- data/lib/graph_weaver/testing/coverage.rb +165 -0
- data/lib/graph_weaver/testing/failure.rb +10 -23
- data/lib/graph_weaver/testing/fake_client.rb +194 -28
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1431 -0
- data/lib/graph_weaver/testing/subgraphs.rb +130 -0
- data/lib/graph_weaver/testing.rb +204 -14
- data/lib/graph_weaver/transport/faraday.rb +31 -6
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +74 -18
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +398 -170
- metadata +20 -3
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "graphql"
|
|
5
|
+
|
|
6
|
+
module GraphWeaver
|
|
7
|
+
# The graphql-ruby schema classes already in this process, and what each
|
|
8
|
+
# one defines. Where {SchemaLoader} *builds* a schema from a source — a
|
|
9
|
+
# path, SDL, an introspection dump — this reads classes the app loaded
|
|
10
|
+
# itself.
|
|
11
|
+
#
|
|
12
|
+
# Two features ask exactly these two questions, and match a schema on the
|
|
13
|
+
# coordinates it defines rather than on its class name: {Testing::Subgraphs}
|
|
14
|
+
# (which schema serves which subgraph) and {Federation::Drift} (has a
|
|
15
|
+
# subgraph changed without a recompose). They share the answers so the two
|
|
16
|
+
# can't drift apart.
|
|
17
|
+
module Schemas
|
|
18
|
+
class << self
|
|
19
|
+
# Every named GraphQL::Schema in the process. An anonymous one is
|
|
20
|
+
# graphql-ruby building from SDL — the router's own view of the
|
|
21
|
+
# supergraph is one — and never an app's subgraph.
|
|
22
|
+
def loaded
|
|
23
|
+
descendants(GraphQL::Schema).select(&:name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Does this schema carry the coordinate — "Type", or "Type.field"?
|
|
27
|
+
def defines?(schema, coordinate)
|
|
28
|
+
type_name, field_name = coordinate.split(".", 2)
|
|
29
|
+
type = schema.get_type(type_name) or return false
|
|
30
|
+
return true unless field_name
|
|
31
|
+
|
|
32
|
+
return type.fields.key?(field_name) if type.respond_to?(:fields)
|
|
33
|
+
# an input object's members are arguments, not fields
|
|
34
|
+
return type.arguments.key?(field_name) if type.respond_to?(:arguments)
|
|
35
|
+
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def descendants(klass)
|
|
42
|
+
klass.subclasses.flat_map { |subclass| [subclass] + descendants(subclass) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -19,7 +19,19 @@ module GraphWeaver
|
|
|
19
19
|
.grep(GraphQL::Language::Nodes::FragmentDefinition)
|
|
20
20
|
.to_h { |fragment| [fragment.name, fragment] }
|
|
21
21
|
|
|
22
|
-
doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition)
|
|
22
|
+
operations = doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition)
|
|
23
|
+
# One file, one operation. Requests do carry operationName now, so a
|
|
24
|
+
# second operation would run fine on the wire — what has no answer is
|
|
25
|
+
# naming: a module is named after its FILE (person.graphql =>
|
|
26
|
+
# PersonQuery), and one file can't name two. A convention, not a limit.
|
|
27
|
+
if operations.size > 1
|
|
28
|
+
names = operations.map { |op| op.name ? "'#{op.name}'" : "an anonymous operation" }
|
|
29
|
+
raise GraphWeaver::Error,
|
|
30
|
+
"document defines #{operations.size} operations (#{names.join(", ")}) — " \
|
|
31
|
+
"split them into one file each, since a module is named after its file"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
operations.first
|
|
23
35
|
end
|
|
24
36
|
|
|
25
37
|
# The schema type an operation's selections start from.
|
|
@@ -32,26 +44,66 @@ module GraphWeaver
|
|
|
32
44
|
end
|
|
33
45
|
|
|
34
46
|
# Flatten a selection set as seen by `type`, yielding (result_key,
|
|
35
|
-
# field_node) per field: plain fields yield directly; inline
|
|
36
|
-
# and named spreads recurse when their type condition applies.
|
|
37
|
-
|
|
47
|
+
# field_node, conditional) per field: plain fields yield directly; inline
|
|
48
|
+
# fragments and named spreads recurse when their type condition applies.
|
|
49
|
+
# `conditional` is true when any fragment on the way down carried
|
|
50
|
+
# @skip/@include — the whole block may be absent from the response, so
|
|
51
|
+
# everything under it is as optional as a directly-skipped field.
|
|
52
|
+
def each_field(type, selections, visiting = Set.new, conditional: false, &block)
|
|
38
53
|
selections.each do |selection|
|
|
39
54
|
case selection
|
|
40
55
|
when GraphQL::Language::Nodes::Field
|
|
41
|
-
yield(selection.alias || selection.name, selection)
|
|
56
|
+
yield(selection.alias || selection.name, selection, conditional)
|
|
42
57
|
when GraphQL::Language::Nodes::InlineFragment
|
|
43
|
-
|
|
58
|
+
next unless applies?(selection.type&.name, type)
|
|
59
|
+
|
|
60
|
+
each_field(type, selection.selections, visiting,
|
|
61
|
+
conditional: conditional || conditional?(selection), &block)
|
|
44
62
|
when GraphQL::Language::Nodes::FragmentSpread
|
|
45
63
|
fragment = @fragments.fetch(selection.name) do
|
|
46
64
|
raise ArgumentError, "unknown fragment: #{selection.name}"
|
|
47
65
|
end
|
|
48
|
-
|
|
66
|
+
if visiting.include?(selection.name)
|
|
67
|
+
raise GraphWeaver::Error, "fragment cycle through #{selection.name}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if applies?(fragment.type.name, type)
|
|
71
|
+
# the directive rides on the SPREAD, not the definition it names
|
|
72
|
+
each_field(type, fragment.selections, visiting | [selection.name],
|
|
73
|
+
conditional: conditional || conditional?(selection), &block)
|
|
74
|
+
end
|
|
49
75
|
else
|
|
50
76
|
raise GraphWeaver::Error, "unsupported selection: #{selection.class}"
|
|
51
77
|
end
|
|
52
78
|
end
|
|
53
79
|
end
|
|
54
80
|
|
|
81
|
+
# each_field grouped by result key: repeated selections of one field
|
|
82
|
+
# (`a { x } a { y }`, or the same field reached through two fragments)
|
|
83
|
+
# collect together, so callers MERGE their sub-selections rather than
|
|
84
|
+
# last-writer-wins. Codegen relies on this; FakeClient/Anonymizer must too,
|
|
85
|
+
# or they'd fabricate/keep a shape the generated struct can't cast.
|
|
86
|
+
def gather(type, selections)
|
|
87
|
+
gather_conditional(type, selections).transform_values { |occurrences| occurrences.map(&:first) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# gather, keeping each occurrence's [field_node, conditional] — the wire
|
|
91
|
+
# key is guaranteed only when SOME occurrence is unconditional.
|
|
92
|
+
def gather_conditional(type, selections)
|
|
93
|
+
out = {}
|
|
94
|
+
each_field(type, selections) { |key, node, conditional| (out[key] ||= []) << [node, conditional] }
|
|
95
|
+
out
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Directives that can drop a selection from the response whatever the
|
|
99
|
+
# schema says — the reason a conditional field's generated type is nilable.
|
|
100
|
+
CONDITIONAL_DIRECTIVES = %w[skip include].freeze
|
|
101
|
+
|
|
102
|
+
# Is this AST node (field, inline fragment, or spread) behind @skip/@include?
|
|
103
|
+
def conditional?(node)
|
|
104
|
+
node.directives.any? { |directive| CONDITIONAL_DIRECTIVES.include?(directive.name) }
|
|
105
|
+
end
|
|
106
|
+
|
|
55
107
|
# A fragment's type condition applies when it names this type exactly,
|
|
56
108
|
# or an interface/union this type belongs to (`... on Named { ... }`).
|
|
57
109
|
def applies?(condition, type)
|
data/lib/graph_weaver/tasks.rb
CHANGED
|
@@ -5,60 +5,255 @@
|
|
|
5
5
|
#
|
|
6
6
|
# require "graph_weaver/tasks"
|
|
7
7
|
#
|
|
8
|
-
# The tasks use the conventional paths (GraphWeaver.
|
|
9
|
-
#
|
|
8
|
+
# The tasks use the conventional paths (GraphWeaver.queries_paths /
|
|
9
|
+
# generated_paths / schema_path — override in your Rakefile or an
|
|
10
10
|
# initializer). Register custom scalars before the tasks run — they're
|
|
11
11
|
# baked into generated source.
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
13
|
+
# Each task names its own subject — generated code, the schema dump, the
|
|
14
|
+
# queries — so which question you're asking is the task name:
|
|
15
|
+
#
|
|
16
|
+
# rake graph_weaver:generate # queries_paths -> generated_paths.first
|
|
17
|
+
# rake graph_weaver:verify # fail if generated files are stale (CI)
|
|
18
|
+
# rake graph_weaver:queries:check # fail if a query no longer validates (CI)
|
|
19
|
+
# rake graph_weaver:schema:diff # fail if the server has drifted from the dump
|
|
20
|
+
# rake graph_weaver:schema:refresh # re-introspect and rewrite the dump
|
|
21
|
+
# rake graph_weaver:federation:diff # fail if the supergraph wasn't recomposed
|
|
22
|
+
# rake graph_weaver:federation:coverage # what the local test router can plan
|
|
23
|
+
# rake graph_weaver:federation:subgraphs # which schema serves which subgraph
|
|
24
|
+
# rake graph_weaver:cassettes:check # fail if a recording no longer casts
|
|
15
25
|
require_relative "../graph_weaver"
|
|
16
26
|
|
|
17
|
-
# in Rails, run app config first (initializers register scalars/enums/
|
|
18
|
-
# helpers, and they're baked into generated source)
|
|
19
|
-
GRAPH_WEAVER_DEPS = Rake::Task.task_defined?("environment") ? ["environment"] : []
|
|
20
|
-
|
|
21
27
|
namespace :graph_weaver do
|
|
22
|
-
|
|
23
|
-
|
|
28
|
+
# In Rails, boot the app first — initializers register scalars/enums/
|
|
29
|
+
# helpers and they're baked into generated source. Rails defines
|
|
30
|
+
# :environment *after* every railtie's rake_tasks block (see
|
|
31
|
+
# Rails::Application#run_tasks_blocks), so whether it exists can only be
|
|
32
|
+
# asked when the task runs, not when this file loads.
|
|
33
|
+
task :environment do
|
|
34
|
+
# These tasks read queries, the schema and the registrations, not a
|
|
35
|
+
# generated module. Saying so lets the railtie skip loading them, so a
|
|
36
|
+
# stale generated file can't block the task that repairs it. It's reset
|
|
37
|
+
# below, so cassettes:check — which does read them — loads them itself.
|
|
38
|
+
GraphWeaver.skip_generated_load = true
|
|
39
|
+
Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
|
|
40
|
+
ensure
|
|
41
|
+
# only meaningful while booting — leaving it set would silently disable
|
|
42
|
+
# loading for anything that boots later in the same process
|
|
43
|
+
GraphWeaver.skip_generated_load = false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
desc "Generate typed query modules (#{GraphWeaver.queries_paths.first} -> #{GraphWeaver.generated_paths.first})"
|
|
47
|
+
task generate: :environment do
|
|
24
48
|
# schema auto-located at GraphWeaver.schema_path, any supported extension
|
|
25
49
|
GraphWeaver.generate!.each { |path| puts "wrote #{path}" }
|
|
50
|
+
rescue GraphWeaver::Error => e
|
|
51
|
+
# a typo'd query is a user error — the message names file, position and
|
|
52
|
+
# fix, and a rake backtrace through codegen only buries it
|
|
53
|
+
abort e.message
|
|
26
54
|
end
|
|
27
55
|
|
|
28
56
|
desc "Verify generated query modules are up to date"
|
|
29
|
-
task verify:
|
|
57
|
+
task verify: :environment do
|
|
30
58
|
GraphWeaver.verify_generated!
|
|
31
59
|
puts "generated queries up to date"
|
|
60
|
+
rescue GraphWeaver::Error => e
|
|
61
|
+
abort e.message
|
|
32
62
|
end
|
|
33
63
|
|
|
34
64
|
namespace :schema do
|
|
35
|
-
# both
|
|
65
|
+
# both re-introspect from the url recorded in the dump
|
|
36
66
|
# (GRAPHWEAVER_AUTH supplies a token for private APIs)
|
|
37
67
|
|
|
38
68
|
desc "Fail when the server's schema has drifted from the local dump"
|
|
39
|
-
task :
|
|
69
|
+
task diff: :environment do
|
|
40
70
|
path = GraphWeaver::SchemaLoader.locate_path or abort "no schema dump at #{GraphWeaver.schema_path}"
|
|
41
71
|
if GraphWeaver::SchemaLoader.stale?(path)
|
|
42
72
|
abort "#{path} is stale — the server's schema has drifted (rake graph_weaver:schema:refresh)"
|
|
43
73
|
end
|
|
44
74
|
|
|
45
75
|
puts "#{path} matches the server"
|
|
76
|
+
rescue GraphWeaver::Error => e
|
|
77
|
+
# e.g. a dump with no recorded url — same clean exit as :refresh
|
|
78
|
+
abort e.message
|
|
46
79
|
end
|
|
47
80
|
|
|
48
|
-
desc "Re-introspect the
|
|
49
|
-
task :
|
|
50
|
-
path = GraphWeaver::SchemaLoader.
|
|
51
|
-
|
|
81
|
+
desc "Re-introspect and rewrite the local dump (URL= to bootstrap the first one)"
|
|
82
|
+
task refresh: :environment do
|
|
83
|
+
path, url = GraphWeaver::SchemaLoader.refresh!(url: ENV["URL"])
|
|
84
|
+
puts "refreshed #{path} from #{url}"
|
|
85
|
+
rescue GraphWeaver::Error => e
|
|
86
|
+
abort e.message
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
namespace :queries do
|
|
91
|
+
desc "Report checked-in queries that no longer validate against the server's schema"
|
|
92
|
+
task check: :environment do
|
|
93
|
+
failures = GraphWeaver.check_queries
|
|
94
|
+
failures.each do |path, errors|
|
|
95
|
+
puts path
|
|
96
|
+
errors.each do |error|
|
|
97
|
+
position = [error["line"], error["column"]].compact.join(":")
|
|
98
|
+
puts " #{position.empty? ? "" : "#{position} "}#{error["message"]}"
|
|
99
|
+
end
|
|
100
|
+
puts
|
|
101
|
+
end
|
|
52
102
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
103
|
+
# abort writes to unbuffered stderr; the detail above went to
|
|
104
|
+
# block-buffered stdout, so a piped CI log shows the verdict first
|
|
105
|
+
$stdout.flush
|
|
106
|
+
abort "#{failures.size} invalid #{(failures.size == 1) ? "query" : "queries"}" if failures.any?
|
|
107
|
+
puts "every query validates against the schema"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
namespace :federation do
|
|
112
|
+
# Every task here answers "which loaded schema serves which subgraph",
|
|
113
|
+
# and Rails leaves config.rake_eager_load false in every environment —
|
|
114
|
+
# so without this each of them reports on zero subgraphs, and :diff
|
|
115
|
+
# exits 0 having compared nothing. Asked when the task runs, not when
|
|
116
|
+
# this file loads: :environment doesn't exist yet at load time.
|
|
117
|
+
task loaded: :environment do
|
|
118
|
+
Rails.application.eager_load! if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# needs no network, so it gates a PR the way verify does
|
|
122
|
+
desc "Fail when a subgraph here changed and the supergraph wasn't recomposed (SUPERGRAPH=)"
|
|
123
|
+
task diff: :loaded do
|
|
124
|
+
require "graph_weaver/federation"
|
|
125
|
+
|
|
126
|
+
supergraph = ENV["SUPERGRAPH"] || GraphWeaver::SchemaLoader.locate_path
|
|
127
|
+
unless supergraph
|
|
128
|
+
abort "pass the composed supergraph: rake graph_weaver:federation:diff " \
|
|
129
|
+
"SUPERGRAPH=supergraph.graphql"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
drift = GraphWeaver::Federation::Drift.new(supergraph:)
|
|
133
|
+
puts drift.report
|
|
134
|
+
|
|
135
|
+
# A partly-local supergraph is a supported setup, so a subgraph this
|
|
136
|
+
# process doesn't serve isn't a failure — but comparing against NONE
|
|
137
|
+
# of them is: the gate passes whatever the subgraphs say, which is
|
|
138
|
+
# worse than failing.
|
|
139
|
+
$stdout.flush
|
|
140
|
+
abort "the supergraph is out of date — recompose it and commit the result" if drift.drift?
|
|
141
|
+
if drift.vacuous?
|
|
142
|
+
abort "this checked nothing, so it proved nothing. No schema in this process defines what " \
|
|
143
|
+
"the supergraph says any of its subgraphs resolves — load them (in Rails, that is " \
|
|
144
|
+
"config.eager_load / config.rake_eager_load), or, if they all run elsewhere, drop this " \
|
|
145
|
+
"task from CI: there is nothing here for it to gate."
|
|
146
|
+
end
|
|
147
|
+
rescue GraphWeaver::Error => e
|
|
148
|
+
abort e.message
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
desc "Show which loaded schema serves each subgraph, as a paste-ready map (SUPERGRAPH=)"
|
|
152
|
+
task subgraphs: :loaded do
|
|
153
|
+
require "graph_weaver/testing"
|
|
154
|
+
|
|
155
|
+
supergraph = ENV["SUPERGRAPH"] || GraphWeaver::SchemaLoader.locate_path
|
|
156
|
+
unless supergraph
|
|
157
|
+
abort "pass the composed supergraph: rake graph_weaver:federation:subgraphs " \
|
|
158
|
+
"SUPERGRAPH=supergraph.graphql"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Testing::Router derives this map itself; this is for reading what
|
|
162
|
+
# detection sees when it refuses, and for committing the map instead.
|
|
163
|
+
table = GraphWeaver::SchemaLoader.routing_table(supergraph)
|
|
164
|
+
rows = table.subgraphs.map do |name|
|
|
165
|
+
found = GraphWeaver::Testing::Subgraphs.candidates(table, name)
|
|
166
|
+
sought = GraphWeaver::Testing::Subgraphs.expected(table, name)
|
|
167
|
+
[name, found, sought]
|
|
168
|
+
end
|
|
169
|
+
width = rows.map { |name, found, _| %("#{name}" => #{found.first&.name || "nil"},).length }.max
|
|
170
|
+
|
|
171
|
+
puts "subgraphs: {"
|
|
172
|
+
rows.each do |name, found, sought|
|
|
173
|
+
entry = %( "#{name}" => #{found.one? ? found.first.name : "nil"},).ljust(width + 2)
|
|
174
|
+
# fields first: every schema has a Query, so only the fields say why
|
|
175
|
+
evidence = (sought.grep(/\./) | sought).first(3).join(", ")
|
|
176
|
+
note = if found.one?
|
|
177
|
+
"# matched: defines #{evidence}"
|
|
178
|
+
elsif found.any?
|
|
179
|
+
"# AMBIGUOUS: #{found.map(&:name).sort.join(", ")} all match — pick one"
|
|
180
|
+
else
|
|
181
|
+
"# no loaded schema defines #{evidence} — fill this in"
|
|
182
|
+
end
|
|
183
|
+
puts "#{entry} #{note}"
|
|
184
|
+
end
|
|
185
|
+
puts "}"
|
|
186
|
+
rescue GraphWeaver::Error => e
|
|
187
|
+
abort e.message
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
desc "Report how many queries the local test router can plan (SUPERGRAPH=, QUERIES=)"
|
|
191
|
+
task coverage: :loaded do
|
|
192
|
+
require "graph_weaver/testing"
|
|
193
|
+
|
|
194
|
+
supergraph = ENV["SUPERGRAPH"] || GraphWeaver::SchemaLoader.locate_path
|
|
195
|
+
unless supergraph
|
|
196
|
+
abort "pass the composed supergraph: rake graph_weaver:federation:coverage " \
|
|
197
|
+
"SUPERGRAPH=supergraph.graphql"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
puts GraphWeaver::Testing::Coverage.new(
|
|
201
|
+
supergraph:,
|
|
202
|
+
queries: ENV["QUERIES"] || GraphWeaver.queries_paths,
|
|
203
|
+
).report
|
|
204
|
+
rescue GraphWeaver::Error => e
|
|
205
|
+
# a supergraph the routing table can't read fully is itself the answer:
|
|
206
|
+
# nothing is plannable, and the message says which construct
|
|
207
|
+
abort e.message
|
|
56
208
|
end
|
|
57
209
|
end
|
|
58
210
|
|
|
59
211
|
namespace :cassettes do
|
|
212
|
+
# The drift the other checks structurally can't see. verify, queries:check
|
|
213
|
+
# and schema:diff all ask about the local side; a cassette is the one
|
|
214
|
+
# artifact recorded from someone else's server, and when that server's
|
|
215
|
+
# answers stop fitting the generated structs the failure surfaces mid-spec
|
|
216
|
+
# as a cast error naming a struct and a sorbet frame — nothing points at
|
|
217
|
+
# the stale file.
|
|
218
|
+
desc "Fail when a recorded response no longer casts into the generated structs"
|
|
219
|
+
task check: :environment do
|
|
220
|
+
require "graph_weaver/testing"
|
|
221
|
+
|
|
222
|
+
# unlike its siblings this task reads generated modules — they are what
|
|
223
|
+
# a recording is checked against
|
|
224
|
+
GraphWeaver.load_generated!
|
|
225
|
+
modules = GraphWeaver.query_files.filter_map do |path|
|
|
226
|
+
name = GraphWeaver.module_name(path, File.read(path))
|
|
227
|
+
Object.const_get(name) if Object.const_defined?(name)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
dir = GraphWeaver::Testing.config.cassette_dir
|
|
231
|
+
checks = Dir[File.join(dir, "*.yml")].sort.map do |path|
|
|
232
|
+
GraphWeaver::Testing::Cassette.new(path).check(modules)
|
|
233
|
+
end
|
|
234
|
+
checks.each { |check| puts check.report }
|
|
235
|
+
|
|
236
|
+
stale = checks.sum { |check| check.stale.size }
|
|
237
|
+
$stdout.flush
|
|
238
|
+
if stale.positive?
|
|
239
|
+
abort "#{stale} stale #{(stale == 1) ? "recording" : "recordings"} — the recorded server's " \
|
|
240
|
+
"answers no longer fit the structs generated from your schema. Re-record " \
|
|
241
|
+
"(GRAPHWEAVER_RECORD=1, with a live client:), or regenerate if it was the schema dump " \
|
|
242
|
+
"that moved: rake graph_weaver:generate."
|
|
243
|
+
end
|
|
244
|
+
if checks.sum(&:checked).zero?
|
|
245
|
+
# a green run that compared nothing is worse than a failure: it would
|
|
246
|
+
# pass whatever the recordings said (see federation:diff)
|
|
247
|
+
abort "this checked nothing, so it proved nothing: no recording in #{dir} carries a query " \
|
|
248
|
+
"any of the #{modules.size} generated modules sends. Drop this task from CI if you " \
|
|
249
|
+
"don't record cassettes, or check that #{dir} is where yours live."
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
puts "every recording still casts"
|
|
253
|
+
end
|
|
254
|
+
|
|
60
255
|
desc "Anonymize every cassette in Testing.config.cassette_dir (PII-safe to commit)"
|
|
61
|
-
task :
|
|
256
|
+
task anonymize: :environment do
|
|
62
257
|
require "graph_weaver/testing"
|
|
63
258
|
|
|
64
259
|
schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
|