graph_weaver 0.5.0 → 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 +537 -0
- data/Gemfile.lock +19 -19
- data/README.md +74 -53
- data/docs/cassettes.md +29 -4
- data/docs/editors.md +3 -1
- data/docs/errors.md +75 -16
- data/docs/federation.md +206 -155
- data/docs/generated_modules.md +223 -166
- data/docs/getting_started.md +106 -82
- data/docs/logging.md +35 -5
- data/docs/scalars.md +119 -24
- data/docs/testing.md +196 -155
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +243 -22
- data/graph_weaver.gemspec +16 -2
- data/lib/generators/graph_weaver/install_generator.rb +31 -16
- data/lib/graph_weaver/client.rb +52 -15
- data/lib/graph_weaver/codegen/aliases.rb +15 -8
- data/lib/graph_weaver/codegen/emit.rb +107 -42
- data/lib/graph_weaver/codegen/enum_type.rb +4 -3
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
- data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
- data/lib/graph_weaver/codegen.rb +382 -105
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +57 -13
- data/lib/graph_weaver/federation.rb +10 -22
- 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 +45 -43
- 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 +6 -13
- 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 +63 -18
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +126 -35
- data/lib/graph_weaver/tasks.rb +88 -36
- data/lib/graph_weaver/testing/cassette.rb +131 -78
- data/lib/graph_weaver/testing/coverage.rb +11 -15
- data/lib/graph_weaver/testing/failure.rb +14 -8
- 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 +147 -840
- data/lib/graph_weaver/testing.rb +40 -83
- 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 +221 -118
- metadata +17 -13
- data/CLAUDE.md +0 -161
- data/DECISIONS.md +0 -309
- data/Makefile +0 -23
- data/NOTES.md +0 -182
- data/PLAN.md +0 -115
- data/REVIEW.md +0 -946
- data/lib/graph_weaver/schemas.rb +0 -46
- 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,48 +42,70 @@ 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)
|
|
46
49
|
end
|
|
47
|
-
private_class_method :sdl_content?
|
|
48
50
|
|
|
49
51
|
def self.load_path(path)
|
|
50
52
|
case File.extname(path)
|
|
51
53
|
when ".json"
|
|
52
|
-
build_introspection(
|
|
54
|
+
build_introspection(parse_json(read_schema(path), path))
|
|
53
55
|
when ".graphql", ".gql"
|
|
54
56
|
build_sdl(read_schema(path))
|
|
55
57
|
else
|
|
56
|
-
raise GraphWeaver::Error,
|
|
57
|
-
"unsupported schema format: #{path} — expected a .json (introspection) or " \
|
|
58
|
-
".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"
|
|
59
61
|
end
|
|
60
62
|
end
|
|
61
63
|
private_class_method :load_path
|
|
62
64
|
|
|
63
65
|
def self.read_schema(path)
|
|
64
|
-
File.read(path)
|
|
66
|
+
File.read(GraphWeaver::Internal::Util.resolve(path))
|
|
65
67
|
rescue SystemCallError => e
|
|
66
68
|
raise GraphWeaver::Error, "can't read the schema at #{path}: #{e.message}"
|
|
67
69
|
end
|
|
68
70
|
private_class_method :read_schema
|
|
69
71
|
|
|
70
|
-
# A
|
|
71
|
-
#
|
|
72
|
-
|
|
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
|
|
73
90
|
# dotted-but-not-a-host: a file whose extension we simply don't read
|
|
74
91
|
FILE_SUFFIXES = %w[yaml yml txt xml sdl md rb erb].freeze
|
|
92
|
+
private_constant :HOST_LIKE, :FILE_SUFFIXES
|
|
75
93
|
|
|
76
|
-
def self.
|
|
94
|
+
def self.url_error(source)
|
|
77
95
|
return unless source.match?(HOST_LIKE)
|
|
78
96
|
|
|
79
97
|
suffix = source[%r{\A[^/:]+}].to_s[/[^.]+\z/].to_s
|
|
80
98
|
return if FILE_SUFFIXES.include?(suffix.downcase)
|
|
81
99
|
|
|
82
|
-
%(
|
|
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"
|
|
83
102
|
end
|
|
84
|
-
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
|
|
85
109
|
|
|
86
110
|
# Build a schema from SDL, first normalizing whichever federation artifact
|
|
87
111
|
# it is: a composed supergraph gets its composition machinery stripped (so
|
|
@@ -129,6 +153,7 @@ module GraphWeaver::SchemaLoader
|
|
|
129
153
|
"specs it declares, a subgraph by applied-but-undeclared @key/@shareable/…",
|
|
130
154
|
introspection: 'an introspection result — it should be the whole envelope, {"data": {"__schema": …}}',
|
|
131
155
|
}.freeze
|
|
156
|
+
private_constant :SOURCE_KINDS
|
|
132
157
|
|
|
133
158
|
# graphql-ruby reports a schema it can't build with whatever its internals
|
|
134
159
|
# happen to raise — NoMethodError, ParseError, a bare RuntimeError — often
|
|
@@ -151,6 +176,7 @@ module GraphWeaver::SchemaLoader
|
|
|
151
176
|
# @join__ marker misses: one that renamed join (`as: "j"`), and a core
|
|
152
177
|
# schema that merged nothing but still carries core__Purpose.
|
|
153
178
|
COMPOSITION_SPEC = %r{@(?:link\s*\(\s*url|core\s*\(\s*feature):\s*"https://specs\.apollo\.dev/(?:join|core)/}
|
|
179
|
+
private_constant :COMPOSITION_SPEC
|
|
154
180
|
|
|
155
181
|
# A composed Fed2 supergraph is marked by @join__* directives (every merged
|
|
156
182
|
# type carries them); a plain schema has none.
|
|
@@ -164,6 +190,7 @@ module GraphWeaver::SchemaLoader
|
|
|
164
190
|
# defines everything it applies (and federation_sdl? catches it first).
|
|
165
191
|
SUBGRAPH_LINK = %r{@link\s*\(\s*url:\s*"https://specs\.apollo\.dev/federation/}
|
|
166
192
|
SUBGRAPH_MARKERS = %w[key external extends provides requires shareable override interfaceObject].freeze
|
|
193
|
+
private_constant :SUBGRAPH_LINK, :SUBGRAPH_MARKERS
|
|
167
194
|
|
|
168
195
|
# A raw subgraph SDL — `rover subgraph fetch`, `_service { sdl }`, or the
|
|
169
196
|
# .graphql in a service repo — rather than a composed graph.
|
|
@@ -173,6 +200,7 @@ module GraphWeaver::SchemaLoader
|
|
|
173
200
|
|
|
174
201
|
SUBGRAPH_MARKERS.any? { |name| sdl.match?(/@#{name}\b/) && !sdl.match?(/\bdirective\s+@#{name}\b/) }
|
|
175
202
|
end
|
|
203
|
+
private_class_method :subgraph_sdl?
|
|
176
204
|
|
|
177
205
|
# The subgraph spec's directives and the types they reference, keyed by
|
|
178
206
|
# what a definition in the SDL would be named ("@key" for a directive).
|
|
@@ -209,6 +237,7 @@ module GraphWeaver::SchemaLoader
|
|
|
209
237
|
"link__Import" => "scalar link__Import",
|
|
210
238
|
"link__Purpose" => "enum link__Purpose { SECURITY EXECUTION }",
|
|
211
239
|
}.freeze
|
|
240
|
+
private_constant :SUBGRAPH_DIRECTIVE_DEFS, :SUBGRAPH_HELPER_TYPES
|
|
212
241
|
|
|
213
242
|
# A fed-2 subgraph that @links the spec under a namespace — `as: "fed"`,
|
|
214
243
|
# and "federation" is the default — applies every non-imported directive
|
|
@@ -308,6 +337,7 @@ module GraphWeaver::SchemaLoader
|
|
|
308
337
|
# only ever adds to this.
|
|
309
338
|
DEFAULT_PREFIXES = %w[join__ link__ core__].freeze
|
|
310
339
|
DEFAULT_DIRECTIVES = %w[link core inaccessible].freeze
|
|
340
|
+
private_constant :LINK_DIRECTIVES, :DEFAULT_PREFIXES, :DEFAULT_DIRECTIVES
|
|
311
341
|
|
|
312
342
|
# What THIS document calls federation's machinery — type-name prefixes,
|
|
313
343
|
# directive names, and the local names @inaccessible answers to — read off
|
|
@@ -375,6 +405,7 @@ module GraphWeaver::SchemaLoader
|
|
|
375
405
|
|
|
376
406
|
VERSION_TAG = /\Av\d+\.\d+\z/
|
|
377
407
|
GRAPHQL_NAME = /\A[A-Za-z][A-Za-z0-9_]*\z/
|
|
408
|
+
private_constant :VERSION_TAG, :GRAPHQL_NAME
|
|
378
409
|
|
|
379
410
|
# The name a linked spec's elements are namespaced under: the URL's
|
|
380
411
|
# penultimate path segment when the last is a version tag, else the last one.
|
|
@@ -427,6 +458,7 @@ module GraphWeaver::SchemaLoader
|
|
|
427
458
|
|
|
428
459
|
GraphQL::Language::Nodes::Document.new(definitions: defs).to_query_string
|
|
429
460
|
end
|
|
461
|
+
private_class_method :strip_federation
|
|
430
462
|
|
|
431
463
|
# a synthetic composition definition to drop: a federation directive
|
|
432
464
|
# definition (by name), or a synthetic join__*/link__* type (by prefix)
|
|
@@ -591,14 +623,14 @@ module GraphWeaver::SchemaLoader
|
|
|
591
623
|
# schema.graphql already sits there
|
|
592
624
|
existing = cache_candidates(cache).find { |candidate| fresh?(candidate, ttl) }
|
|
593
625
|
if existing
|
|
594
|
-
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}" }
|
|
595
627
|
return load(existing)
|
|
596
628
|
end
|
|
597
629
|
|
|
598
|
-
GraphWeaver.log(:info) { "schema cache miss: #{cache}" }
|
|
630
|
+
GraphWeaver::Internal::Log.log(:info) { "schema cache miss: #{cache}" }
|
|
599
631
|
end
|
|
600
632
|
|
|
601
|
-
result = GraphWeaver.log_timed(:info, "introspected #{endpoint(transport)}") do
|
|
633
|
+
result = GraphWeaver::Internal::Log.log_timed(:info, "introspected #{endpoint(transport)}") do
|
|
602
634
|
transport.execute(GraphQL::Introspection.query, variables: {}).to_h
|
|
603
635
|
end
|
|
604
636
|
if (errors = result["errors"])
|
|
@@ -617,7 +649,6 @@ module GraphWeaver::SchemaLoader
|
|
|
617
649
|
schema = GraphQL::Schema.from_introspection(result)
|
|
618
650
|
|
|
619
651
|
if cache
|
|
620
|
-
FileUtils.mkdir_p(File.dirname(cache))
|
|
621
652
|
# the extension picks the format: .json is the verbatim wire
|
|
622
653
|
# artifact; .graphql/.gql is SDL — human-readable, PR-reviewable
|
|
623
654
|
# diffs (both generate byte-identical code)
|
|
@@ -628,8 +659,17 @@ module GraphWeaver::SchemaLoader
|
|
|
628
659
|
header = meta && "# graph_weaver: #{JSON.generate(meta)}\n\n"
|
|
629
660
|
"#{header}#{schema.to_definition}"
|
|
630
661
|
end
|
|
631
|
-
|
|
632
|
-
|
|
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)" }
|
|
633
673
|
end
|
|
634
674
|
|
|
635
675
|
schema
|
|
@@ -661,13 +701,18 @@ module GraphWeaver::SchemaLoader
|
|
|
661
701
|
# The provenance recorded in a dump ({"url" => ..., "introspected_at"
|
|
662
702
|
# => ..., "auth_env" => ...}), whichever format holds it; nil for
|
|
663
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.
|
|
664
707
|
def self.provenance(path)
|
|
665
|
-
content = File.read(path)
|
|
708
|
+
content = File.read(GraphWeaver::Internal::Util.resolve(path))
|
|
666
709
|
if path.end_with?(".json")
|
|
667
710
|
JSON.parse(content)["graph_weaver"]
|
|
668
711
|
elsif (meta = content[/\A# graph_weaver: (\{.*\})$/, 1])
|
|
669
712
|
JSON.parse(meta)
|
|
670
713
|
end
|
|
714
|
+
rescue JSON::ParserError
|
|
715
|
+
nil
|
|
671
716
|
end
|
|
672
717
|
|
|
673
718
|
# The ENV var a dump's token lives in — whichever the generator recorded,
|
|
@@ -675,19 +720,20 @@ module GraphWeaver::SchemaLoader
|
|
|
675
720
|
# that authenticates and rake tasks that 401.
|
|
676
721
|
def self.auth_env(path = nil)
|
|
677
722
|
# the first refresh names a dump that doesn't exist yet
|
|
678
|
-
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))
|
|
679
724
|
recorded || DEFAULT_AUTH_ENV
|
|
680
725
|
end
|
|
726
|
+
private_class_method :auth_env
|
|
681
727
|
|
|
682
|
-
# Re-introspect a dump's source and compare —
|
|
683
|
-
#
|
|
684
|
-
# etc); by default one is built from the
|
|
685
|
-
# as `rake graph_weaver:schema:diff
|
|
686
|
-
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)
|
|
687
733
|
transport ||= source_transport(path)
|
|
688
734
|
fresh = introspect(transport)
|
|
689
735
|
|
|
690
|
-
|
|
736
|
+
GraphWeaver::SchemaDiff.new(load(path), fresh, source: path, target: endpoint(transport).to_s)
|
|
691
737
|
end
|
|
692
738
|
|
|
693
739
|
# Re-introspect and rewrite the local dump, returning [path, url].
|
|
@@ -718,8 +764,10 @@ module GraphWeaver::SchemaLoader
|
|
|
718
764
|
end
|
|
719
765
|
private_class_method :refresh_hint
|
|
720
766
|
|
|
721
|
-
#
|
|
722
|
-
#
|
|
767
|
+
# A transport to the dump's recorded url, authenticated from whichever ENV
|
|
768
|
+
# var the dump named (else DEFAULT_AUTH_ENV). The single way to reach a
|
|
769
|
+
# dump's own server — building one at a call site is how `--auth MY_TOKEN`
|
|
770
|
+
# ends up honoured in some places and not others.
|
|
723
771
|
def self.source_transport(path)
|
|
724
772
|
meta = provenance(path)
|
|
725
773
|
unless meta&.key?("url")
|
|
@@ -730,7 +778,6 @@ module GraphWeaver::SchemaLoader
|
|
|
730
778
|
|
|
731
779
|
GraphWeaver.new(meta["url"], auth: ENV[auth_env(path)]).transport
|
|
732
780
|
end
|
|
733
|
-
private_class_method :source_transport
|
|
734
781
|
|
|
735
782
|
# Where a dump came from, recorded into the file so it can be
|
|
736
783
|
# re-verified later — a parsable header comment in SDL, a
|
|
@@ -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
|
|
@@ -938,6 +993,19 @@ module GraphWeaver::SchemaLoader
|
|
|
938
993
|
owners(type_name, field_name)
|
|
939
994
|
end
|
|
940
995
|
|
|
996
|
+
# A `subgraphs:` map with string keys, refusing a name this supergraph
|
|
997
|
+
# doesn't have — a typo'd key would otherwise silently configure nothing.
|
|
998
|
+
# Lives here so the test router and the drift check refuse identically:
|
|
999
|
+
# two copies of this drifted apart once already.
|
|
1000
|
+
def named_subgraphs(given)
|
|
1001
|
+
map = (given || {}).to_h { |name, schema| [name.to_s, schema] }
|
|
1002
|
+
unknown = map.keys - subgraphs
|
|
1003
|
+
return map if unknown.empty?
|
|
1004
|
+
|
|
1005
|
+
raise GraphWeaver::ConfigurationError, "subgraphs: names #{unknown.join(", ")}, which " \
|
|
1006
|
+
"this supergraph doesn't have (its subgraphs are #{subgraphs.join(", ")})"
|
|
1007
|
+
end
|
|
1008
|
+
|
|
941
1009
|
# The @key field sets a subgraph will answer an `_entities` fetch on, each
|
|
942
1010
|
# as a list of dotted paths ("id organization { id }" => ["id",
|
|
943
1011
|
# "organization.id"]). A `resolvable: false` key declares a shape this
|
|
@@ -994,16 +1062,27 @@ module GraphWeaver::SchemaLoader
|
|
|
994
1062
|
|
|
995
1063
|
# join__Graph's enum values ARE the subgraphs: ACCOUNTS
|
|
996
1064
|
# @join__graph(name: "accounts", url: "...").
|
|
1065
|
+
#
|
|
1066
|
+
# This table reads the join spec under its default name, and only that —
|
|
1067
|
+
# a supergraph that renamed it (`@link(url: ".../join/v0.3", as: "j")`)
|
|
1068
|
+
# spells every marker `j__` and lands here with nothing found. Being told
|
|
1069
|
+
# a composed graph has no subgraphs is worse than being refused, so an
|
|
1070
|
+
# empty read is `unsupported` rather than an answer.
|
|
997
1071
|
def read_graphs
|
|
998
1072
|
enum = @document.definitions.find do |defn|
|
|
999
1073
|
defn.is_a?(GraphQL::Language::Nodes::EnumTypeDefinition) && defn.name == "join__Graph"
|
|
1000
1074
|
end
|
|
1001
|
-
return unless enum
|
|
1002
1075
|
|
|
1003
|
-
enum
|
|
1076
|
+
enum&.values&.each do |value|
|
|
1004
1077
|
name = argument(value.directives.find { |d| d.name == "join__graph" }, "name")
|
|
1005
1078
|
@names[value.name] = name if name
|
|
1006
1079
|
end
|
|
1080
|
+
|
|
1081
|
+
return if @names.any?
|
|
1082
|
+
|
|
1083
|
+
@unsupported << "no join__Graph enum names the subgraphs — this schema declares a " \
|
|
1084
|
+
"composition spec, so either it merged nothing, or it renamed the join spec " \
|
|
1085
|
+
"(@link(url: \".../join/v0.3\", as: \"...\")), which this table doesn't follow"
|
|
1007
1086
|
end
|
|
1008
1087
|
|
|
1009
1088
|
# What each abstract type can be, from the SDL alone — a union's members,
|
|
@@ -1087,6 +1166,7 @@ module GraphWeaver::SchemaLoader
|
|
|
1087
1166
|
resolvable.filter_map { |d| argument(d, "requires") }.first,
|
|
1088
1167
|
resolvable.filter_map { |d| argument(d, "provides") }.first,
|
|
1089
1168
|
applied.filter_map { |d| argument(d, "override") }.first,
|
|
1169
|
+
applied.flat_map { |d| context_arguments(d) },
|
|
1090
1170
|
)]
|
|
1091
1171
|
end.to_h
|
|
1092
1172
|
end
|
|
@@ -1102,6 +1182,17 @@ module GraphWeaver::SchemaLoader
|
|
|
1102
1182
|
end
|
|
1103
1183
|
end
|
|
1104
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
|
+
|
|
1105
1196
|
# the subgraph NAME a directive's graph: argument points at
|
|
1106
1197
|
def subgraph(directive) = @names[argument(directive, "graph")]
|
|
1107
1198
|
|
data/lib/graph_weaver/tasks.rb
CHANGED
|
@@ -24,6 +24,36 @@
|
|
|
24
24
|
# rake graph_weaver:cassettes:check # fail if a recording no longer casts
|
|
25
25
|
require_relative "../graph_weaver"
|
|
26
26
|
|
|
27
|
+
module GraphWeaver
|
|
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
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
27
57
|
namespace :graph_weaver do
|
|
28
58
|
# In Rails, boot the app first — initializers register scalars/enums/
|
|
29
59
|
# helpers and they're baked into generated source. Rails defines
|
|
@@ -43,10 +73,24 @@ namespace :graph_weaver do
|
|
|
43
73
|
GraphWeaver.skip_generated_load = false
|
|
44
74
|
end
|
|
45
75
|
|
|
46
|
-
|
|
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)"
|
|
47
80
|
task generate: :environment do
|
|
81
|
+
glob = File.join(GraphWeaver::Internal::Util.resolve(GraphWeaver.generated_paths.first), "**/*.rb")
|
|
82
|
+
before = Dir[glob]
|
|
83
|
+
|
|
48
84
|
# schema auto-located at GraphWeaver.schema_path, any supported extension
|
|
49
|
-
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
|
|
50
94
|
rescue GraphWeaver::Error => e
|
|
51
95
|
# a typo'd query is a user error — the message names file, position and
|
|
52
96
|
# fix, and a rake backtrace through codegen only buries it
|
|
@@ -57,6 +101,7 @@ namespace :graph_weaver do
|
|
|
57
101
|
task verify: :environment do
|
|
58
102
|
GraphWeaver.verify_generated!
|
|
59
103
|
puts "generated queries up to date"
|
|
104
|
+
GraphWeaver::Internal::Tasks.report_unmatched
|
|
60
105
|
rescue GraphWeaver::Error => e
|
|
61
106
|
abort e.message
|
|
62
107
|
end
|
|
@@ -67,12 +112,18 @@ namespace :graph_weaver do
|
|
|
67
112
|
|
|
68
113
|
desc "Fail when the server's schema has drifted from the local dump"
|
|
69
114
|
task diff: :environment do
|
|
70
|
-
path = GraphWeaver::SchemaLoader.locate_path or abort
|
|
71
|
-
|
|
72
|
-
|
|
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)"
|
|
73
126
|
end
|
|
74
|
-
|
|
75
|
-
puts "#{path} matches the server"
|
|
76
127
|
rescue GraphWeaver::Error => e
|
|
77
128
|
# e.g. a dump with no recorded url — same clean exit as :refresh
|
|
78
129
|
abort e.message
|
|
@@ -80,8 +131,14 @@ namespace :graph_weaver do
|
|
|
80
131
|
|
|
81
132
|
desc "Re-introspect and rewrite the local dump (URL= to bootstrap the first one)"
|
|
82
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
|
+
|
|
83
140
|
path, url = GraphWeaver::SchemaLoader.refresh!(url: ENV["URL"])
|
|
84
|
-
puts "refreshed #{path} from #{url}"
|
|
141
|
+
puts "refreshed #{GraphWeaver::Internal::Util.relative(path)} from #{url}"
|
|
85
142
|
rescue GraphWeaver::Error => e
|
|
86
143
|
abort e.message
|
|
87
144
|
end
|
|
@@ -123,12 +180,7 @@ namespace :graph_weaver do
|
|
|
123
180
|
task diff: :loaded do
|
|
124
181
|
require "graph_weaver/federation"
|
|
125
182
|
|
|
126
|
-
supergraph =
|
|
127
|
-
unless supergraph
|
|
128
|
-
abort "pass the composed supergraph: rake graph_weaver:federation:diff " \
|
|
129
|
-
"SUPERGRAPH=supergraph.graphql"
|
|
130
|
-
end
|
|
131
|
-
|
|
183
|
+
supergraph = GraphWeaver::Internal::Tasks.supergraph!("diff")
|
|
132
184
|
drift = GraphWeaver::Federation::Drift.new(supergraph:)
|
|
133
185
|
puts drift.report
|
|
134
186
|
|
|
@@ -152,18 +204,14 @@ namespace :graph_weaver do
|
|
|
152
204
|
task subgraphs: :loaded do
|
|
153
205
|
require "graph_weaver/testing"
|
|
154
206
|
|
|
155
|
-
supergraph =
|
|
156
|
-
unless supergraph
|
|
157
|
-
abort "pass the composed supergraph: rake graph_weaver:federation:subgraphs " \
|
|
158
|
-
"SUPERGRAPH=supergraph.graphql"
|
|
159
|
-
end
|
|
207
|
+
supergraph = GraphWeaver::Internal::Tasks.supergraph!("subgraphs")
|
|
160
208
|
|
|
161
209
|
# Testing::Router derives this map itself; this is for reading what
|
|
162
210
|
# detection sees when it refuses, and for committing the map instead.
|
|
163
211
|
table = GraphWeaver::SchemaLoader.routing_table(supergraph)
|
|
164
212
|
rows = table.subgraphs.map do |name|
|
|
165
|
-
found = GraphWeaver::
|
|
166
|
-
sought = GraphWeaver::
|
|
213
|
+
found = GraphWeaver::Internal::Subgraphs.candidates(table, name)
|
|
214
|
+
sought = GraphWeaver::Internal::Subgraphs.expected(table, name)
|
|
167
215
|
[name, found, sought]
|
|
168
216
|
end
|
|
169
217
|
width = rows.map { |name, found, _| %("#{name}" => #{found.first&.name || "nil"},).length }.max
|
|
@@ -191,14 +239,8 @@ namespace :graph_weaver do
|
|
|
191
239
|
task coverage: :loaded do
|
|
192
240
|
require "graph_weaver/testing"
|
|
193
241
|
|
|
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
242
|
puts GraphWeaver::Testing::Coverage.new(
|
|
201
|
-
supergraph
|
|
243
|
+
supergraph: GraphWeaver::Internal::Tasks.supergraph!("coverage"),
|
|
202
244
|
queries: ENV["QUERIES"] || GraphWeaver.queries_paths,
|
|
203
245
|
).report
|
|
204
246
|
rescue GraphWeaver::Error => e
|
|
@@ -222,12 +264,15 @@ namespace :graph_weaver do
|
|
|
222
264
|
# unlike its siblings this task reads generated modules — they are what
|
|
223
265
|
# a recording is checked against
|
|
224
266
|
GraphWeaver.load_generated!
|
|
225
|
-
modules = GraphWeaver.query_files.filter_map do |path|
|
|
226
|
-
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))
|
|
227
269
|
Object.const_get(name) if Object.const_defined?(name)
|
|
228
270
|
end
|
|
229
271
|
|
|
230
|
-
|
|
272
|
+
# Testing.cassette_dir, not config.cassette_dir: the configured path is
|
|
273
|
+
# relative by default and rake runs from wherever it runs from
|
|
274
|
+
dir = GraphWeaver::Testing.cassette_dir
|
|
275
|
+
shown = GraphWeaver::Internal::Util.relative(dir)
|
|
231
276
|
checks = Dir[File.join(dir, "*.yml")].sort.map do |path|
|
|
232
277
|
GraphWeaver::Testing::Cassette.new(path).check(modules)
|
|
233
278
|
end
|
|
@@ -244,9 +289,9 @@ namespace :graph_weaver do
|
|
|
244
289
|
if checks.sum(&:checked).zero?
|
|
245
290
|
# a green run that compared nothing is worse than a failure: it would
|
|
246
291
|
# pass whatever the recordings said (see federation:diff)
|
|
247
|
-
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 " \
|
|
248
293
|
"any of the #{modules.size} generated modules sends. Drop this task from CI if you " \
|
|
249
|
-
"don't record cassettes, or check that #{
|
|
294
|
+
"don't record cassettes, or check that #{shown} is where yours live."
|
|
250
295
|
end
|
|
251
296
|
|
|
252
297
|
puts "every recording still casts"
|
|
@@ -256,11 +301,18 @@ namespace :graph_weaver do
|
|
|
256
301
|
task anonymize: :environment do
|
|
257
302
|
require "graph_weaver/testing"
|
|
258
303
|
|
|
259
|
-
|
|
260
|
-
|
|
304
|
+
# locate, not schema_path: the dump is whichever supported extension is
|
|
305
|
+
# actually on disk, and every sibling task asks the same way
|
|
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|
|
|
261
310
|
GraphWeaver::Testing::Cassette.new(path).anonymize!(schema:)
|
|
262
|
-
puts "anonymized #{path}"
|
|
311
|
+
puts "anonymized #{GraphWeaver::Internal::Util.relative(path)}"
|
|
263
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?
|
|
264
316
|
end
|
|
265
317
|
end
|
|
266
318
|
end
|