graph_weaver 0.5.1 → 0.6.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 +409 -0
- data/Gemfile.lock +19 -19
- data/README.md +74 -53
- data/docs/cassettes.md +6 -1
- data/docs/editors.md +3 -1
- data/docs/errors.md +73 -16
- data/docs/federation.md +201 -151
- data/docs/generated_modules.md +222 -165
- data/docs/getting_started.md +105 -81
- data/docs/logging.md +34 -4
- data/docs/scalars.md +119 -24
- data/docs/testing.md +191 -151
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +210 -11
- data/lib/generators/graph_weaver/install_generator.rb +16 -1
- data/lib/graph_weaver/client.rb +46 -13
- data/lib/graph_weaver/codegen/aliases.rb +5 -4
- data/lib/graph_weaver/codegen/emit.rb +96 -39
- data/lib/graph_weaver/codegen/enum_type.rb +3 -0
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +82 -79
- data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
- data/lib/graph_weaver/codegen.rb +284 -84
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +30 -7
- data/lib/graph_weaver/federation.rb +6 -5
- data/lib/graph_weaver/hints.rb +76 -2
- data/lib/graph_weaver/in_process.rb +11 -8
- data/lib/graph_weaver/inflect.rb +2 -0
- data/lib/graph_weaver/input_struct.rb +115 -12
- data/lib/graph_weaver/internal/overrides.rb +101 -0
- data/lib/graph_weaver/internal/planner.rb +868 -0
- data/lib/graph_weaver/internal/schemas.rb +50 -0
- data/lib/graph_weaver/internal/selection.rb +127 -0
- data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
- data/lib/graph_weaver/internal/values.rb +181 -0
- data/lib/graph_weaver/internal.rb +206 -0
- data/lib/graph_weaver/logging.rb +108 -20
- data/lib/graph_weaver/parsing.rb +5 -4
- data/lib/graph_weaver/query_module.rb +2 -0
- data/lib/graph_weaver/railtie.rb +113 -14
- data/lib/graph_weaver/representation.rb +30 -2
- data/lib/graph_weaver/response.rb +15 -0
- data/lib/graph_weaver/retry.rb +54 -22
- data/lib/graph_weaver/rspec.rb +50 -11
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +96 -29
- data/lib/graph_weaver/tasks.rb +78 -29
- data/lib/graph_weaver/testing/cassette.rb +49 -65
- data/lib/graph_weaver/testing/coverage.rb +5 -4
- data/lib/graph_weaver/testing/failure.rb +10 -6
- data/lib/graph_weaver/testing/fake_client.rb +253 -60
- data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
- data/lib/graph_weaver/testing/router.rb +94 -808
- data/lib/graph_weaver/testing.rb +35 -84
- data/lib/graph_weaver/transport/faraday.rb +1 -1
- data/lib/graph_weaver/transport/http.rb +29 -12
- data/lib/graph_weaver/transport.rb +11 -34
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +188 -110
- metadata +10 -5
- data/lib/graph_weaver/schemas.rb +0 -48
- data/lib/graph_weaver/selection.rb +0 -120
- data/lib/graph_weaver/testing/values.rb +0 -98
|
@@ -5,6 +5,8 @@ require "fileutils"
|
|
|
5
5
|
require "graphql"
|
|
6
6
|
require "json"
|
|
7
7
|
require_relative "errors"
|
|
8
|
+
require_relative "internal"
|
|
9
|
+
require_relative "schema_diff"
|
|
8
10
|
|
|
9
11
|
# Load a schema for codegen from either format a remote service can hand
|
|
10
12
|
# you — introspection JSON or SDL, as a file path or the content itself —
|
|
@@ -25,7 +27,7 @@ module GraphWeaver::SchemaLoader
|
|
|
25
27
|
source = source.to_path if source.respond_to?(:to_path)
|
|
26
28
|
|
|
27
29
|
if source.lstrip.start_with?("{") # introspection JSON content
|
|
28
|
-
build_introspection(
|
|
30
|
+
build_introspection(parse_json(source, "the schema content"))
|
|
29
31
|
elsif sdl_content?(source) # SDL content, one line or many
|
|
30
32
|
build_sdl(source)
|
|
31
33
|
elsif source.include?("\n") # content, but nothing we recognize
|
|
@@ -40,6 +42,7 @@ module GraphWeaver::SchemaLoader
|
|
|
40
42
|
# `type` definition — and a one-line `type Query { hi: String }`, the shape
|
|
41
43
|
# you'd type in a console, still is.
|
|
42
44
|
SDL_CONTENT = /\A\s*(?:\#|"|(?:schema|type|interface|union|enum|scalar|directive|input|extend)\b[\s{(@])/
|
|
45
|
+
private_constant :SDL_CONTENT
|
|
43
46
|
|
|
44
47
|
def self.sdl_content?(source)
|
|
45
48
|
source.match?(SDL_CONTENT)
|
|
@@ -48,39 +51,61 @@ module GraphWeaver::SchemaLoader
|
|
|
48
51
|
def self.load_path(path)
|
|
49
52
|
case File.extname(path)
|
|
50
53
|
when ".json"
|
|
51
|
-
build_introspection(
|
|
54
|
+
build_introspection(parse_json(read_schema(path), path))
|
|
52
55
|
when ".graphql", ".gql"
|
|
53
56
|
build_sdl(read_schema(path))
|
|
54
57
|
else
|
|
55
|
-
raise GraphWeaver::Error,
|
|
56
|
-
"unsupported schema format: #{path} — expected a .json (introspection) or " \
|
|
57
|
-
".graphql/.gql (SDL) path, or the content itself
|
|
58
|
+
raise GraphWeaver::Error, url_error(path) ||
|
|
59
|
+
"unsupported schema format: #{truncate(path)} — expected a .json (introspection) or " \
|
|
60
|
+
".graphql/.gql (SDL) path, or the content itself"
|
|
58
61
|
end
|
|
59
62
|
end
|
|
60
63
|
private_class_method :load_path
|
|
61
64
|
|
|
62
65
|
def self.read_schema(path)
|
|
63
|
-
File.read(path)
|
|
66
|
+
File.read(GraphWeaver::Internal::Util.resolve(path))
|
|
64
67
|
rescue SystemCallError => e
|
|
65
68
|
raise GraphWeaver::Error, "can't read the schema at #{path}: #{e.message}"
|
|
66
69
|
end
|
|
67
70
|
private_class_method :read_schema
|
|
68
71
|
|
|
69
|
-
# A
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
+
# A .json that isn't JSON is the corrupt-dump case — a truncated download,
|
|
73
|
+
# an interrupted write, a login page saved over it. JSON::ParserError names
|
|
74
|
+
# neither the file nor what it holds, and isn't under the Error umbrella.
|
|
75
|
+
def self.parse_json(text, source)
|
|
76
|
+
JSON.parse(text)
|
|
77
|
+
rescue JSON::ParserError => e
|
|
78
|
+
holds = text.strip.empty? ? "it is empty" : "it starts #{text.lstrip[0, 60].inspect}"
|
|
79
|
+
raise GraphWeaver::Error,
|
|
80
|
+
"#{source} isn't JSON (#{e.message}) — an introspection dump is the whole envelope, " \
|
|
81
|
+
"{\"data\": {\"__schema\": …}}, and #{holds}"
|
|
82
|
+
end
|
|
83
|
+
private_class_method :parse_json
|
|
84
|
+
|
|
85
|
+
# A url with its scheme left off is the near miss worth leading with:
|
|
86
|
+
# "unsupported schema format" sends you looking at the filesystem when what
|
|
87
|
+
# you have is an endpoint. A dotted host, or any host carrying a port —
|
|
88
|
+
# localhost:4000 is the likeliest one to type and has no dot at all.
|
|
89
|
+
HOST_LIKE = %r{\A[a-z0-9-]+(?:(?:\.[a-z0-9-]+)+(?::\d+)?|:\d+)(?:/\S*)?\z}i
|
|
72
90
|
# dotted-but-not-a-host: a file whose extension we simply don't read
|
|
73
91
|
FILE_SUFFIXES = %w[yaml yml txt xml sdl md rb erb].freeze
|
|
92
|
+
private_constant :HOST_LIKE, :FILE_SUFFIXES
|
|
74
93
|
|
|
75
|
-
def self.
|
|
94
|
+
def self.url_error(source)
|
|
76
95
|
return unless source.match?(HOST_LIKE)
|
|
77
96
|
|
|
78
97
|
suffix = source[%r{\A[^/:]+}].to_s[/[^.]+\z/].to_s
|
|
79
98
|
return if FILE_SUFFIXES.include?(suffix.downcase)
|
|
80
99
|
|
|
81
|
-
%(
|
|
100
|
+
%("#{source}" looks like a url, not a path — did you mean "https://#{source}"? ) +
|
|
101
|
+
"A schema source is a .json (introspection) or .graphql/.gql (SDL) dump, or the content itself"
|
|
82
102
|
end
|
|
83
|
-
private_class_method :
|
|
103
|
+
private_class_method :url_error
|
|
104
|
+
|
|
105
|
+
# a source we can't read is quoted back so it's recognizable, not reprinted
|
|
106
|
+
# — an unrecognized 3 MB dump would otherwise BE the error message
|
|
107
|
+
def self.truncate(source) = (source.length > 120) ? "#{source[0, 120]}…" : source
|
|
108
|
+
private_class_method :truncate
|
|
84
109
|
|
|
85
110
|
# Build a schema from SDL, first normalizing whichever federation artifact
|
|
86
111
|
# it is: a composed supergraph gets its composition machinery stripped (so
|
|
@@ -128,6 +153,7 @@ module GraphWeaver::SchemaLoader
|
|
|
128
153
|
"specs it declares, a subgraph by applied-but-undeclared @key/@shareable/…",
|
|
129
154
|
introspection: 'an introspection result — it should be the whole envelope, {"data": {"__schema": …}}',
|
|
130
155
|
}.freeze
|
|
156
|
+
private_constant :SOURCE_KINDS
|
|
131
157
|
|
|
132
158
|
# graphql-ruby reports a schema it can't build with whatever its internals
|
|
133
159
|
# happen to raise — NoMethodError, ParseError, a bare RuntimeError — often
|
|
@@ -150,6 +176,7 @@ module GraphWeaver::SchemaLoader
|
|
|
150
176
|
# @join__ marker misses: one that renamed join (`as: "j"`), and a core
|
|
151
177
|
# schema that merged nothing but still carries core__Purpose.
|
|
152
178
|
COMPOSITION_SPEC = %r{@(?:link\s*\(\s*url|core\s*\(\s*feature):\s*"https://specs\.apollo\.dev/(?:join|core)/}
|
|
179
|
+
private_constant :COMPOSITION_SPEC
|
|
153
180
|
|
|
154
181
|
# A composed Fed2 supergraph is marked by @join__* directives (every merged
|
|
155
182
|
# type carries them); a plain schema has none.
|
|
@@ -163,6 +190,7 @@ module GraphWeaver::SchemaLoader
|
|
|
163
190
|
# defines everything it applies (and federation_sdl? catches it first).
|
|
164
191
|
SUBGRAPH_LINK = %r{@link\s*\(\s*url:\s*"https://specs\.apollo\.dev/federation/}
|
|
165
192
|
SUBGRAPH_MARKERS = %w[key external extends provides requires shareable override interfaceObject].freeze
|
|
193
|
+
private_constant :SUBGRAPH_LINK, :SUBGRAPH_MARKERS
|
|
166
194
|
|
|
167
195
|
# A raw subgraph SDL — `rover subgraph fetch`, `_service { sdl }`, or the
|
|
168
196
|
# .graphql in a service repo — rather than a composed graph.
|
|
@@ -172,6 +200,7 @@ module GraphWeaver::SchemaLoader
|
|
|
172
200
|
|
|
173
201
|
SUBGRAPH_MARKERS.any? { |name| sdl.match?(/@#{name}\b/) && !sdl.match?(/\bdirective\s+@#{name}\b/) }
|
|
174
202
|
end
|
|
203
|
+
private_class_method :subgraph_sdl?
|
|
175
204
|
|
|
176
205
|
# The subgraph spec's directives and the types they reference, keyed by
|
|
177
206
|
# what a definition in the SDL would be named ("@key" for a directive).
|
|
@@ -208,6 +237,7 @@ module GraphWeaver::SchemaLoader
|
|
|
208
237
|
"link__Import" => "scalar link__Import",
|
|
209
238
|
"link__Purpose" => "enum link__Purpose { SECURITY EXECUTION }",
|
|
210
239
|
}.freeze
|
|
240
|
+
private_constant :SUBGRAPH_DIRECTIVE_DEFS, :SUBGRAPH_HELPER_TYPES
|
|
211
241
|
|
|
212
242
|
# A fed-2 subgraph that @links the spec under a namespace — `as: "fed"`,
|
|
213
243
|
# and "federation" is the default — applies every non-imported directive
|
|
@@ -307,6 +337,7 @@ module GraphWeaver::SchemaLoader
|
|
|
307
337
|
# only ever adds to this.
|
|
308
338
|
DEFAULT_PREFIXES = %w[join__ link__ core__].freeze
|
|
309
339
|
DEFAULT_DIRECTIVES = %w[link core inaccessible].freeze
|
|
340
|
+
private_constant :LINK_DIRECTIVES, :DEFAULT_PREFIXES, :DEFAULT_DIRECTIVES
|
|
310
341
|
|
|
311
342
|
# What THIS document calls federation's machinery — type-name prefixes,
|
|
312
343
|
# directive names, and the local names @inaccessible answers to — read off
|
|
@@ -374,6 +405,7 @@ module GraphWeaver::SchemaLoader
|
|
|
374
405
|
|
|
375
406
|
VERSION_TAG = /\Av\d+\.\d+\z/
|
|
376
407
|
GRAPHQL_NAME = /\A[A-Za-z][A-Za-z0-9_]*\z/
|
|
408
|
+
private_constant :VERSION_TAG, :GRAPHQL_NAME
|
|
377
409
|
|
|
378
410
|
# The name a linked spec's elements are namespaced under: the URL's
|
|
379
411
|
# penultimate path segment when the last is a version tag, else the last one.
|
|
@@ -426,6 +458,7 @@ module GraphWeaver::SchemaLoader
|
|
|
426
458
|
|
|
427
459
|
GraphQL::Language::Nodes::Document.new(definitions: defs).to_query_string
|
|
428
460
|
end
|
|
461
|
+
private_class_method :strip_federation
|
|
429
462
|
|
|
430
463
|
# a synthetic composition definition to drop: a federation directive
|
|
431
464
|
# definition (by name), or a synthetic join__*/link__* type (by prefix)
|
|
@@ -590,14 +623,14 @@ module GraphWeaver::SchemaLoader
|
|
|
590
623
|
# schema.graphql already sits there
|
|
591
624
|
existing = cache_candidates(cache).find { |candidate| fresh?(candidate, ttl) }
|
|
592
625
|
if existing
|
|
593
|
-
GraphWeaver.log(:info) { "schema cache hit: #{existing}#{" (ttl #{ttl}s)" if ttl}" }
|
|
626
|
+
GraphWeaver::Internal::Log.log(:info) { "schema cache hit: #{existing}#{" (ttl #{ttl}s)" if ttl}" }
|
|
594
627
|
return load(existing)
|
|
595
628
|
end
|
|
596
629
|
|
|
597
|
-
GraphWeaver.log(:info) { "schema cache miss: #{cache}" }
|
|
630
|
+
GraphWeaver::Internal::Log.log(:info) { "schema cache miss: #{cache}" }
|
|
598
631
|
end
|
|
599
632
|
|
|
600
|
-
result = GraphWeaver.log_timed(:info, "introspected #{endpoint(transport)}") do
|
|
633
|
+
result = GraphWeaver::Internal::Log.log_timed(:info, "introspected #{endpoint(transport)}") do
|
|
601
634
|
transport.execute(GraphQL::Introspection.query, variables: {}).to_h
|
|
602
635
|
end
|
|
603
636
|
if (errors = result["errors"])
|
|
@@ -616,7 +649,6 @@ module GraphWeaver::SchemaLoader
|
|
|
616
649
|
schema = GraphQL::Schema.from_introspection(result)
|
|
617
650
|
|
|
618
651
|
if cache
|
|
619
|
-
FileUtils.mkdir_p(File.dirname(cache))
|
|
620
652
|
# the extension picks the format: .json is the verbatim wire
|
|
621
653
|
# artifact; .graphql/.gql is SDL — human-readable, PR-reviewable
|
|
622
654
|
# diffs (both generate byte-identical code)
|
|
@@ -627,8 +659,17 @@ module GraphWeaver::SchemaLoader
|
|
|
627
659
|
header = meta && "# graph_weaver: #{JSON.generate(meta)}\n\n"
|
|
628
660
|
"#{header}#{schema.to_definition}"
|
|
629
661
|
end
|
|
630
|
-
|
|
631
|
-
|
|
662
|
+
begin
|
|
663
|
+
FileUtils.mkdir_p(File.dirname(cache))
|
|
664
|
+
GraphWeaver::Internal::Util.atomic_write(cache, content)
|
|
665
|
+
rescue SystemCallError => e
|
|
666
|
+
# the introspection worked and the write didn't, which a bare Errno
|
|
667
|
+
# says neither of — and cache: is the argument to change
|
|
668
|
+
raise GraphWeaver::Error,
|
|
669
|
+
"introspected #{endpoint(transport)} but couldn't write the schema cache " \
|
|
670
|
+
"to #{cache}: #{e.message}"
|
|
671
|
+
end
|
|
672
|
+
GraphWeaver::Internal::Log.log(:info) { "wrote schema cache: #{cache} (#{content.bytesize} bytes)" }
|
|
632
673
|
end
|
|
633
674
|
|
|
634
675
|
schema
|
|
@@ -660,13 +701,18 @@ module GraphWeaver::SchemaLoader
|
|
|
660
701
|
# The provenance recorded in a dump ({"url" => ..., "introspected_at"
|
|
661
702
|
# => ..., "auth_env" => ...}), whichever format holds it; nil for
|
|
662
703
|
# local/unannotated dumps.
|
|
704
|
+
# A corrupt dump records nothing readable, which is what nil says — and
|
|
705
|
+
# `schema:refresh` is the fix for one, so it must not be the thing that
|
|
706
|
+
# trips over it.
|
|
663
707
|
def self.provenance(path)
|
|
664
|
-
content = File.read(path)
|
|
708
|
+
content = File.read(GraphWeaver::Internal::Util.resolve(path))
|
|
665
709
|
if path.end_with?(".json")
|
|
666
710
|
JSON.parse(content)["graph_weaver"]
|
|
667
711
|
elsif (meta = content[/\A# graph_weaver: (\{.*\})$/, 1])
|
|
668
712
|
JSON.parse(meta)
|
|
669
713
|
end
|
|
714
|
+
rescue JSON::ParserError
|
|
715
|
+
nil
|
|
670
716
|
end
|
|
671
717
|
|
|
672
718
|
# The ENV var a dump's token lives in — whichever the generator recorded,
|
|
@@ -674,19 +720,20 @@ module GraphWeaver::SchemaLoader
|
|
|
674
720
|
# that authenticates and rake tasks that 401.
|
|
675
721
|
def self.auth_env(path = nil)
|
|
676
722
|
# the first refresh names a dump that doesn't exist yet
|
|
677
|
-
recorded = provenance(path)&.dig("auth_env") if path && File.exist?(path)
|
|
723
|
+
recorded = provenance(path)&.dig("auth_env") if path && File.exist?(GraphWeaver::Internal::Util.resolve(path))
|
|
678
724
|
recorded || DEFAULT_AUTH_ENV
|
|
679
725
|
end
|
|
726
|
+
private_class_method :auth_env
|
|
680
727
|
|
|
681
|
-
# Re-introspect a dump's source and compare —
|
|
682
|
-
#
|
|
683
|
-
# etc); by default one is built from the
|
|
684
|
-
# as `rake graph_weaver:schema:diff
|
|
685
|
-
def self.
|
|
728
|
+
# Re-introspect a dump's source and compare — a {SchemaDiff} naming what
|
|
729
|
+
# moved, empty when the server still matches what's on disk. transport:
|
|
730
|
+
# overrides the transport (auth etc); by default one is built from the
|
|
731
|
+
# dump's recorded url. Wired up as `rake graph_weaver:schema:diff`.
|
|
732
|
+
def self.diff(path, transport: nil)
|
|
686
733
|
transport ||= source_transport(path)
|
|
687
734
|
fresh = introspect(transport)
|
|
688
735
|
|
|
689
|
-
|
|
736
|
+
GraphWeaver::SchemaDiff.new(load(path), fresh, source: path, target: endpoint(transport).to_s)
|
|
690
737
|
end
|
|
691
738
|
|
|
692
739
|
# Re-introspect and rewrite the local dump, returning [path, url].
|
|
@@ -750,13 +797,16 @@ module GraphWeaver::SchemaLoader
|
|
|
750
797
|
private_class_method :stamp
|
|
751
798
|
|
|
752
799
|
CACHE_EXTENSIONS = %w[.json .graphql .gql].freeze
|
|
800
|
+
private_constant :CACHE_EXTENSIONS
|
|
753
801
|
|
|
754
802
|
# cache: true / :json / :graphql / :gql / a path => the file to write
|
|
755
803
|
# (nil for no caching). Symbols and true anchor at GraphWeaver.schema_path —
|
|
756
804
|
# the schema dump the generation workflow reads, so one file serves both
|
|
757
805
|
# (introspect caches it, rake generate loads it).
|
|
758
806
|
def self.cache_path(cache)
|
|
759
|
-
|
|
807
|
+
# Rails.root.join(...) hands you a Pathname, as schema: and query: already take
|
|
808
|
+
cache = cache.to_path if cache.respond_to?(:to_path)
|
|
809
|
+
path = case cache
|
|
760
810
|
when nil, false
|
|
761
811
|
nil
|
|
762
812
|
when true
|
|
@@ -774,11 +824,13 @@ module GraphWeaver::SchemaLoader
|
|
|
774
824
|
|
|
775
825
|
cache
|
|
776
826
|
end
|
|
827
|
+
path && GraphWeaver::Internal::Util.resolve(path)
|
|
777
828
|
end
|
|
778
829
|
private_class_method :cache_path
|
|
779
830
|
|
|
780
831
|
# the requested path first, then its siblings in the other formats
|
|
781
832
|
def self.cache_candidates(path)
|
|
833
|
+
path = GraphWeaver::Internal::Util.resolve(path)
|
|
782
834
|
base = strip_extension(path)
|
|
783
835
|
[path, *CACHE_EXTENSIONS.map { |ext| base + ext }].uniq
|
|
784
836
|
end
|
|
@@ -839,8 +891,10 @@ module GraphWeaver::SchemaLoader
|
|
|
839
891
|
# One field's routing: `graphs` resolve it, `external` declare it without
|
|
840
892
|
# resolving it (an @external copy exists so that subgraph can @key or
|
|
841
893
|
# @requires on it), and requires/provides/override carry the field sets and
|
|
842
|
-
# the migration marker verbatim.
|
|
843
|
-
|
|
894
|
+
# the migration marker verbatim. `contextual` names the arguments a
|
|
895
|
+
# @fromContext fills from an ancestor selection (federation 2.8) — a fetch
|
|
896
|
+
# has to supply them, so whoever plans one needs to know they exist.
|
|
897
|
+
Field = Struct.new(:graphs, :external, :requires, :provides, :override, :contextual)
|
|
844
898
|
|
|
845
899
|
# Every @join__ directive this table understands. One it doesn't is a
|
|
846
900
|
# federation construct nobody has taught it to read, and it lands in
|
|
@@ -850,6 +904,7 @@ module GraphWeaver::SchemaLoader
|
|
|
850
904
|
join__type join__field join__graph join__implements
|
|
851
905
|
join__unionMember join__enumValue join__owner
|
|
852
906
|
].to_set.freeze
|
|
907
|
+
private_constant :Field, :KNOWN
|
|
853
908
|
|
|
854
909
|
# every subgraph in the graph, in the order the supergraph declares them
|
|
855
910
|
attr_reader :subgraphs
|
|
@@ -1111,6 +1166,7 @@ module GraphWeaver::SchemaLoader
|
|
|
1111
1166
|
resolvable.filter_map { |d| argument(d, "requires") }.first,
|
|
1112
1167
|
resolvable.filter_map { |d| argument(d, "provides") }.first,
|
|
1113
1168
|
applied.filter_map { |d| argument(d, "override") }.first,
|
|
1169
|
+
applied.flat_map { |d| context_arguments(d) },
|
|
1114
1170
|
)]
|
|
1115
1171
|
end.to_h
|
|
1116
1172
|
end
|
|
@@ -1126,6 +1182,17 @@ module GraphWeaver::SchemaLoader
|
|
|
1126
1182
|
end
|
|
1127
1183
|
end
|
|
1128
1184
|
|
|
1185
|
+
# The argument names a @join__field says come from a @context. The rest of
|
|
1186
|
+
# each entry (the context's name, and the selection read out of it) is the
|
|
1187
|
+
# gateway's business; what a planner needs is that the fetch it builds
|
|
1188
|
+
# would leave these unset.
|
|
1189
|
+
def context_arguments(directive)
|
|
1190
|
+
entries = argument(directive, "contextArguments")
|
|
1191
|
+
return [] unless entries.is_a?(Array)
|
|
1192
|
+
|
|
1193
|
+
entries.filter_map { |entry| argument(entry, "name") }
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1129
1196
|
# the subgraph NAME a directive's graph: argument points at
|
|
1130
1197
|
def subgraph(directive) = @names[argument(directive, "graph")]
|
|
1131
1198
|
|
data/lib/graph_weaver/tasks.rb
CHANGED
|
@@ -25,15 +25,31 @@
|
|
|
25
25
|
require_relative "../graph_weaver"
|
|
26
26
|
|
|
27
27
|
module GraphWeaver
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
28
|
+
module Internal
|
|
29
|
+
# helpers the rake tasks share
|
|
30
|
+
module Tasks
|
|
31
|
+
# The composed supergraph a federation task reads: SUPERGRAPH=, else the
|
|
32
|
+
# conventional dump when that is what it is. Aborts naming the task, so
|
|
33
|
+
# the message says the command to retype.
|
|
34
|
+
def self.supergraph!(task)
|
|
35
|
+
ENV["SUPERGRAPH"] || GraphWeaver::SchemaLoader.locate_path ||
|
|
36
|
+
abort("pass the composed supergraph: rake graph_weaver:federation:#{task} " \
|
|
37
|
+
"SUPERGRAPH=supergraph.graphql")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Registrations the run couldn't match, once each. The logger is the
|
|
41
|
+
# runtime channel and is silent by default; this task's own output is the
|
|
42
|
+
# build channel, and the build is where someone regenerating is looking.
|
|
43
|
+
def self.report_unmatched
|
|
44
|
+
GraphWeaver.unmatched_registrations.each { |message| puts message }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Neither task that needs the committed dump can take one itself, so both
|
|
48
|
+
# say which task can — the same sentence SchemaLoader gives on refresh.
|
|
49
|
+
def self.no_dump
|
|
50
|
+
"no schema dump at #{GraphWeaver.schema_path} — take one: " \
|
|
51
|
+
"rake graph_weaver:schema:refresh URL=https://api.example.com/graphql"
|
|
52
|
+
end
|
|
37
53
|
end
|
|
38
54
|
end
|
|
39
55
|
end
|
|
@@ -57,10 +73,24 @@ namespace :graph_weaver do
|
|
|
57
73
|
GraphWeaver.skip_generated_load = false
|
|
58
74
|
end
|
|
59
75
|
|
|
60
|
-
|
|
76
|
+
# the default, not GraphWeaver.queries_paths: a desc is baked when this file
|
|
77
|
+
# loads, which in Rails is before :environment has run an initializer that
|
|
78
|
+
# moves it — interpolating would print the default as though it were the setting
|
|
79
|
+
desc "Generate typed query modules (default app/graphql/queries -> app/graphql/generated)"
|
|
61
80
|
task generate: :environment do
|
|
81
|
+
glob = File.join(GraphWeaver::Internal::Util.resolve(GraphWeaver.generated_paths.first), "**/*.rb")
|
|
82
|
+
before = Dir[glob]
|
|
83
|
+
|
|
62
84
|
# schema auto-located at GraphWeaver.schema_path, any supported extension
|
|
63
|
-
GraphWeaver.generate
|
|
85
|
+
written = GraphWeaver.generate!
|
|
86
|
+
changed = GraphWeaver.changed_files
|
|
87
|
+
changed.each { |path| puts "wrote #{path}" }
|
|
88
|
+
puts "#{written.size - changed.size} already up to date" if changed.size < written.size
|
|
89
|
+
# generated files are checked in, so a delete this task made is a diff the
|
|
90
|
+
# user is about to find; a run that printed nothing at all had done both
|
|
91
|
+
(before - Dir[glob]).each { |path| puts "pruned #{GraphWeaver::Internal::Util.relative(path)}" }
|
|
92
|
+
puts "no queries in #{GraphWeaver.queries_paths.join(", ")}" if written.empty?
|
|
93
|
+
GraphWeaver::Internal::Tasks.report_unmatched
|
|
64
94
|
rescue GraphWeaver::Error => e
|
|
65
95
|
# a typo'd query is a user error — the message names file, position and
|
|
66
96
|
# fix, and a rake backtrace through codegen only buries it
|
|
@@ -71,6 +101,7 @@ namespace :graph_weaver do
|
|
|
71
101
|
task verify: :environment do
|
|
72
102
|
GraphWeaver.verify_generated!
|
|
73
103
|
puts "generated queries up to date"
|
|
104
|
+
GraphWeaver::Internal::Tasks.report_unmatched
|
|
74
105
|
rescue GraphWeaver::Error => e
|
|
75
106
|
abort e.message
|
|
76
107
|
end
|
|
@@ -81,12 +112,18 @@ namespace :graph_weaver do
|
|
|
81
112
|
|
|
82
113
|
desc "Fail when the server's schema has drifted from the local dump"
|
|
83
114
|
task diff: :environment do
|
|
84
|
-
path = GraphWeaver::SchemaLoader.locate_path or abort
|
|
85
|
-
|
|
86
|
-
|
|
115
|
+
path = GraphWeaver::SchemaLoader.locate_path or abort GraphWeaver::Internal::Tasks.no_dump
|
|
116
|
+
diff = GraphWeaver::SchemaLoader.diff(path)
|
|
117
|
+
dump = GraphWeaver::Internal::Util.relative(path)
|
|
118
|
+
if diff.empty?
|
|
119
|
+
puts "#{dump} matches the server"
|
|
120
|
+
else
|
|
121
|
+
puts diff.report
|
|
122
|
+
# abort writes to unbuffered stderr; the summary above went to
|
|
123
|
+
# block-buffered stdout, so a piped CI log shows it first
|
|
124
|
+
$stdout.flush
|
|
125
|
+
abort "#{dump} is stale — the server's schema has drifted (rake graph_weaver:schema:refresh)"
|
|
87
126
|
end
|
|
88
|
-
|
|
89
|
-
puts "#{path} matches the server"
|
|
90
127
|
rescue GraphWeaver::Error => e
|
|
91
128
|
# e.g. a dump with no recorded url — same clean exit as :refresh
|
|
92
129
|
abort e.message
|
|
@@ -94,8 +131,14 @@ namespace :graph_weaver do
|
|
|
94
131
|
|
|
95
132
|
desc "Re-introspect and rewrite the local dump (URL= to bootstrap the first one)"
|
|
96
133
|
task refresh: :environment do
|
|
134
|
+
# anything else in URL= reaches introspection as a schema *source*, and
|
|
135
|
+
# fails talking about file extensions rather than the flag just typed
|
|
136
|
+
if ENV["URL"] && !ENV["URL"].match?(GraphWeaver::Client::URL)
|
|
137
|
+
abort "URL= takes an endpoint: rake graph_weaver:schema:refresh URL=https://api.example.com/graphql"
|
|
138
|
+
end
|
|
139
|
+
|
|
97
140
|
path, url = GraphWeaver::SchemaLoader.refresh!(url: ENV["URL"])
|
|
98
|
-
puts "refreshed #{path} from #{url}"
|
|
141
|
+
puts "refreshed #{GraphWeaver::Internal::Util.relative(path)} from #{url}"
|
|
99
142
|
rescue GraphWeaver::Error => e
|
|
100
143
|
abort e.message
|
|
101
144
|
end
|
|
@@ -137,7 +180,7 @@ namespace :graph_weaver do
|
|
|
137
180
|
task diff: :loaded do
|
|
138
181
|
require "graph_weaver/federation"
|
|
139
182
|
|
|
140
|
-
supergraph = GraphWeaver::Tasks.supergraph!("diff")
|
|
183
|
+
supergraph = GraphWeaver::Internal::Tasks.supergraph!("diff")
|
|
141
184
|
drift = GraphWeaver::Federation::Drift.new(supergraph:)
|
|
142
185
|
puts drift.report
|
|
143
186
|
|
|
@@ -161,14 +204,14 @@ namespace :graph_weaver do
|
|
|
161
204
|
task subgraphs: :loaded do
|
|
162
205
|
require "graph_weaver/testing"
|
|
163
206
|
|
|
164
|
-
supergraph = GraphWeaver::Tasks.supergraph!("subgraphs")
|
|
207
|
+
supergraph = GraphWeaver::Internal::Tasks.supergraph!("subgraphs")
|
|
165
208
|
|
|
166
209
|
# Testing::Router derives this map itself; this is for reading what
|
|
167
210
|
# detection sees when it refuses, and for committing the map instead.
|
|
168
211
|
table = GraphWeaver::SchemaLoader.routing_table(supergraph)
|
|
169
212
|
rows = table.subgraphs.map do |name|
|
|
170
|
-
found = GraphWeaver::
|
|
171
|
-
sought = GraphWeaver::
|
|
213
|
+
found = GraphWeaver::Internal::Subgraphs.candidates(table, name)
|
|
214
|
+
sought = GraphWeaver::Internal::Subgraphs.expected(table, name)
|
|
172
215
|
[name, found, sought]
|
|
173
216
|
end
|
|
174
217
|
width = rows.map { |name, found, _| %("#{name}" => #{found.first&.name || "nil"},).length }.max
|
|
@@ -197,7 +240,7 @@ namespace :graph_weaver do
|
|
|
197
240
|
require "graph_weaver/testing"
|
|
198
241
|
|
|
199
242
|
puts GraphWeaver::Testing::Coverage.new(
|
|
200
|
-
supergraph: GraphWeaver::Tasks.supergraph!("coverage"),
|
|
243
|
+
supergraph: GraphWeaver::Internal::Tasks.supergraph!("coverage"),
|
|
201
244
|
queries: ENV["QUERIES"] || GraphWeaver.queries_paths,
|
|
202
245
|
).report
|
|
203
246
|
rescue GraphWeaver::Error => e
|
|
@@ -221,14 +264,15 @@ namespace :graph_weaver do
|
|
|
221
264
|
# unlike its siblings this task reads generated modules — they are what
|
|
222
265
|
# a recording is checked against
|
|
223
266
|
GraphWeaver.load_generated!
|
|
224
|
-
modules = GraphWeaver.query_files.filter_map do |path|
|
|
225
|
-
name = GraphWeaver.module_name(path, File.read(path))
|
|
267
|
+
modules = GraphWeaver::Internal::Util.query_files.filter_map do |path|
|
|
268
|
+
name = GraphWeaver::Internal::Util.module_name(path, File.read(path))
|
|
226
269
|
Object.const_get(name) if Object.const_defined?(name)
|
|
227
270
|
end
|
|
228
271
|
|
|
229
272
|
# Testing.cassette_dir, not config.cassette_dir: the configured path is
|
|
230
273
|
# relative by default and rake runs from wherever it runs from
|
|
231
274
|
dir = GraphWeaver::Testing.cassette_dir
|
|
275
|
+
shown = GraphWeaver::Internal::Util.relative(dir)
|
|
232
276
|
checks = Dir[File.join(dir, "*.yml")].sort.map do |path|
|
|
233
277
|
GraphWeaver::Testing::Cassette.new(path).check(modules)
|
|
234
278
|
end
|
|
@@ -245,9 +289,9 @@ namespace :graph_weaver do
|
|
|
245
289
|
if checks.sum(&:checked).zero?
|
|
246
290
|
# a green run that compared nothing is worse than a failure: it would
|
|
247
291
|
# pass whatever the recordings said (see federation:diff)
|
|
248
|
-
abort "this checked nothing, so it proved nothing: no recording in #{
|
|
292
|
+
abort "this checked nothing, so it proved nothing: no recording in #{shown} carries a query " \
|
|
249
293
|
"any of the #{modules.size} generated modules sends. Drop this task from CI if you " \
|
|
250
|
-
"don't record cassettes, or check that #{
|
|
294
|
+
"don't record cassettes, or check that #{shown} is where yours live."
|
|
251
295
|
end
|
|
252
296
|
|
|
253
297
|
puts "every recording still casts"
|
|
@@ -259,11 +303,16 @@ namespace :graph_weaver do
|
|
|
259
303
|
|
|
260
304
|
# locate, not schema_path: the dump is whichever supported extension is
|
|
261
305
|
# actually on disk, and every sibling task asks the same way
|
|
262
|
-
schema = GraphWeaver::SchemaLoader.locate or abort
|
|
263
|
-
|
|
306
|
+
schema = GraphWeaver::SchemaLoader.locate or abort GraphWeaver::Internal::Tasks.no_dump
|
|
307
|
+
dir = GraphWeaver::Testing.cassette_dir
|
|
308
|
+
paths = Dir[File.join(dir, "*.yml")].sort
|
|
309
|
+
paths.each do |path|
|
|
264
310
|
GraphWeaver::Testing::Cassette.new(path).anonymize!(schema:)
|
|
265
|
-
puts "anonymized #{path}"
|
|
311
|
+
puts "anonymized #{GraphWeaver::Internal::Util.relative(path)}"
|
|
266
312
|
end
|
|
313
|
+
# silence and exit 0 read as "done" — say where we looked, the way every
|
|
314
|
+
# sibling task does
|
|
315
|
+
puts "no recordings in #{GraphWeaver::Internal::Util.relative(dir)}" if paths.empty?
|
|
267
316
|
end
|
|
268
317
|
end
|
|
269
318
|
end
|