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
|
@@ -17,52 +17,393 @@ module GraphWeaver::SchemaLoader
|
|
|
17
17
|
# so a cache round-trip is symmetrical with introspect:
|
|
18
18
|
# SchemaLoader.load(cached_json) # from Rails.cache/redis/...
|
|
19
19
|
def self.load(source)
|
|
20
|
-
return
|
|
20
|
+
return build_introspection(source) if source.is_a?(Hash)
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
raise ArgumentError, "unsupported schema content: #{source.lstrip[0, 80].inspect}"
|
|
27
|
-
end
|
|
22
|
+
# Rails.root.join(...) hands you a Pathname, and to_path is the
|
|
23
|
+
# ecosystem's "I am a path" (File.open honors it). Without this the
|
|
24
|
+
# sniffing below fails as `undefined method 'lstrip'`.
|
|
25
|
+
source = source.to_path if source.respond_to?(:to_path)
|
|
28
26
|
|
|
27
|
+
if source.lstrip.start_with?("{") # introspection JSON content
|
|
28
|
+
build_introspection(JSON.parse(source))
|
|
29
|
+
elsif sdl_content?(source) # SDL content, one line or many
|
|
29
30
|
build_sdl(source)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
elsif source.include?("\n") # content, but nothing we recognize
|
|
32
|
+
raise GraphWeaver::Error, "unsupported schema content: #{source.lstrip[0, 80].inspect}"
|
|
33
|
+
else
|
|
34
|
+
load_path(source)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# SDL content rather than a file path. Anchored at the start and requiring
|
|
39
|
+
# a delimiter after the keyword, so `types/schema.graphql` isn't read as a
|
|
40
|
+
# `type` definition — and a one-line `type Query { hi: String }`, the shape
|
|
41
|
+
# you'd type in a console, still is.
|
|
42
|
+
SDL_CONTENT = /\A\s*(?:\#|"|(?:schema|type|interface|union|enum|scalar|directive|input|extend)\b[\s{(@])/
|
|
43
|
+
|
|
44
|
+
def self.sdl_content?(source)
|
|
45
|
+
source.match?(SDL_CONTENT)
|
|
46
|
+
end
|
|
47
|
+
private_class_method :sdl_content?
|
|
48
|
+
|
|
49
|
+
def self.load_path(path)
|
|
50
|
+
case File.extname(path)
|
|
51
|
+
when ".json"
|
|
52
|
+
build_introspection(JSON.parse(read_schema(path)))
|
|
53
|
+
when ".graphql", ".gql"
|
|
54
|
+
build_sdl(read_schema(path))
|
|
55
|
+
else
|
|
56
|
+
raise GraphWeaver::Error,
|
|
57
|
+
"unsupported schema format: #{path} — expected a .json (introspection) or " \
|
|
58
|
+
".graphql/.gql (SDL) path, or the content itself#{url_hint(path)}"
|
|
39
59
|
end
|
|
40
60
|
end
|
|
61
|
+
private_class_method :load_path
|
|
62
|
+
|
|
63
|
+
def self.read_schema(path)
|
|
64
|
+
File.read(path)
|
|
65
|
+
rescue SystemCallError => e
|
|
66
|
+
raise GraphWeaver::Error, "can't read the schema at #{path}: #{e.message}"
|
|
67
|
+
end
|
|
68
|
+
private_class_method :read_schema
|
|
69
|
+
|
|
70
|
+
# A bare host is the near miss worth naming: "unsupported schema format"
|
|
71
|
+
# sends you looking at the filesystem when the cause is the missing scheme.
|
|
72
|
+
HOST_LIKE = %r{\A[a-z0-9-]+(?:\.[a-z0-9-]+)+(?::\d+)?(?:/\S*)?\z}i
|
|
73
|
+
# dotted-but-not-a-host: a file whose extension we simply don't read
|
|
74
|
+
FILE_SUFFIXES = %w[yaml yml txt xml sdl md rb erb].freeze
|
|
75
|
+
|
|
76
|
+
def self.url_hint(source)
|
|
77
|
+
return unless source.match?(HOST_LIKE)
|
|
78
|
+
|
|
79
|
+
suffix = source[%r{\A[^/:]+}].to_s[/[^.]+\z/].to_s
|
|
80
|
+
return if FILE_SUFFIXES.include?(suffix.downcase)
|
|
81
|
+
|
|
82
|
+
%( — "#{source}" looks like a host; did you mean "https://#{source}"?)
|
|
83
|
+
end
|
|
84
|
+
private_class_method :url_hint
|
|
41
85
|
|
|
42
|
-
# Build a schema from SDL, first
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
# actually serves
|
|
46
|
-
# gone rather than leaked into schema.types
|
|
86
|
+
# Build a schema from SDL, first normalizing whichever federation artifact
|
|
87
|
+
# it is: a composed supergraph gets its composition machinery stripped (so
|
|
88
|
+
# a supergraph dump — often the only artifact for the merged graph, and
|
|
89
|
+
# what the router actually serves — loads like any schema, with the
|
|
90
|
+
# plumbing gone rather than leaked into schema.types); a subgraph gets the
|
|
91
|
+
# federation directive definitions it applies but doesn't declare. Plain
|
|
92
|
+
# SDL passes through.
|
|
47
93
|
def self.build_sdl(sdl)
|
|
48
|
-
|
|
94
|
+
kind = sdl_kind(sdl)
|
|
95
|
+
prepared = case kind
|
|
96
|
+
when :supergraph then strip_federation(sdl)
|
|
97
|
+
when :subgraph then add_subgraph_definitions(sdl)
|
|
98
|
+
else sdl
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
build(kind) { GraphQL::Schema.from_definition(prepared) }
|
|
49
102
|
end
|
|
50
103
|
private_class_method :build_sdl
|
|
51
104
|
|
|
105
|
+
def self.build_introspection(result)
|
|
106
|
+
build(:introspection) { GraphQL::Schema.from_introspection(result) }
|
|
107
|
+
end
|
|
108
|
+
private_class_method :build_introspection
|
|
109
|
+
|
|
110
|
+
# Which artifact an SDL string is — it decides both the normalizing it
|
|
111
|
+
# needs and what to say when it won't build.
|
|
112
|
+
def self.sdl_kind(sdl)
|
|
113
|
+
if federation_sdl?(sdl)
|
|
114
|
+
:supergraph
|
|
115
|
+
elsif subgraph_sdl?(sdl)
|
|
116
|
+
:subgraph
|
|
117
|
+
else
|
|
118
|
+
:sdl
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
private_class_method :sdl_kind
|
|
122
|
+
|
|
123
|
+
SOURCE_KINDS = {
|
|
124
|
+
supergraph: "a federation supergraph SDL — if it's hand-written, check that nothing " \
|
|
125
|
+
"left in it still references an @inaccessible element",
|
|
126
|
+
subgraph: "a federation subgraph SDL — a directive it applies may be outside the " \
|
|
127
|
+
"subgraph spec; declare that one in the file",
|
|
128
|
+
sdl: "plain SDL — a supergraph is recognized by its @join__* markers or the @link/@core " \
|
|
129
|
+
"specs it declares, a subgraph by applied-but-undeclared @key/@shareable/…",
|
|
130
|
+
introspection: 'an introspection result — it should be the whole envelope, {"data": {"__schema": …}}',
|
|
131
|
+
}.freeze
|
|
132
|
+
|
|
133
|
+
# graphql-ruby reports a schema it can't build with whatever its internals
|
|
134
|
+
# happen to raise — NoMethodError, ParseError, a bare RuntimeError — often
|
|
135
|
+
# naming a document the caller never wrote (we reprint federation SDL before
|
|
136
|
+
# building). Keep those under the umbrella, and say which artifact we took
|
|
137
|
+
# the source for.
|
|
138
|
+
def self.build(kind)
|
|
139
|
+
yield
|
|
140
|
+
rescue GraphWeaver::Error
|
|
141
|
+
raise
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
raise GraphWeaver::Error,
|
|
144
|
+
"couldn't build a schema from #{SOURCE_KINDS.fetch(kind)} (#{e.class}: #{e.message})"
|
|
145
|
+
end
|
|
146
|
+
private_class_method :build
|
|
147
|
+
|
|
148
|
+
# A composed graph declares the specs that compose it: fed-2 links join,
|
|
149
|
+
# fed-1 @cores the core spec itself. Neither matches a subgraph, which links
|
|
150
|
+
# only the federation spec — so this catches the composed graphs the
|
|
151
|
+
# @join__ marker misses: one that renamed join (`as: "j"`), and a core
|
|
152
|
+
# schema that merged nothing but still carries core__Purpose.
|
|
153
|
+
COMPOSITION_SPEC = %r{@(?:link\s*\(\s*url|core\s*\(\s*feature):\s*"https://specs\.apollo\.dev/(?:join|core)/}
|
|
154
|
+
|
|
52
155
|
# A composed Fed2 supergraph is marked by @join__* directives (every merged
|
|
53
156
|
# type carries them); a plain schema has none.
|
|
54
157
|
def self.federation_sdl?(sdl)
|
|
55
|
-
sdl.match?(/@join__\w/)
|
|
158
|
+
sdl.match?(/@join__\w/) || sdl.match?(COMPOSITION_SPEC)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The federation spec a fed-2 subgraph @links, and the directives a fed-1
|
|
162
|
+
# subgraph just applies. Both leave the definitions off the file: fed-1
|
|
163
|
+
# treats them as implicit, fed-2 imports them. A composed supergraph
|
|
164
|
+
# defines everything it applies (and federation_sdl? catches it first).
|
|
165
|
+
SUBGRAPH_LINK = %r{@link\s*\(\s*url:\s*"https://specs\.apollo\.dev/federation/}
|
|
166
|
+
SUBGRAPH_MARKERS = %w[key external extends provides requires shareable override interfaceObject].freeze
|
|
167
|
+
|
|
168
|
+
# A raw subgraph SDL — `rover subgraph fetch`, `_service { sdl }`, or the
|
|
169
|
+
# .graphql in a service repo — rather than a composed graph.
|
|
170
|
+
def self.subgraph_sdl?(sdl)
|
|
171
|
+
return false if federation_sdl?(sdl)
|
|
172
|
+
return true if sdl.match?(SUBGRAPH_LINK)
|
|
173
|
+
|
|
174
|
+
SUBGRAPH_MARKERS.any? { |name| sdl.match?(/@#{name}\b/) && !sdl.match?(/\bdirective\s+@#{name}\b/) }
|
|
56
175
|
end
|
|
57
176
|
|
|
58
|
-
|
|
59
|
-
|
|
177
|
+
# The subgraph spec's directives and the types they reference, keyed by
|
|
178
|
+
# what a definition in the SDL would be named ("@key" for a directive).
|
|
179
|
+
# The helper scalars are spelled `federation__*` — they are weaver's own
|
|
180
|
+
# injections into someone else's schema, and a subgraph with its own
|
|
181
|
+
# `FieldSet` type must not collide with one.
|
|
182
|
+
# https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/reference/subgraph-spec
|
|
183
|
+
SUBGRAPH_DIRECTIVE_DEFS = {
|
|
184
|
+
"@key" => "directive @key(fields: federation__FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE",
|
|
185
|
+
"@external" => "directive @external on OBJECT | FIELD_DEFINITION",
|
|
186
|
+
"@requires" => "directive @requires(fields: federation__FieldSet!) on FIELD_DEFINITION",
|
|
187
|
+
"@provides" => "directive @provides(fields: federation__FieldSet!) on FIELD_DEFINITION",
|
|
188
|
+
"@shareable" => "directive @shareable repeatable on OBJECT | FIELD_DEFINITION",
|
|
189
|
+
"@extends" => "directive @extends on OBJECT | INTERFACE",
|
|
190
|
+
"@override" => "directive @override(from: String!, label: String) on FIELD_DEFINITION",
|
|
191
|
+
"@interfaceObject" => "directive @interfaceObject on OBJECT",
|
|
192
|
+
"@inaccessible" => "directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION",
|
|
193
|
+
"@tag" => "directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA",
|
|
194
|
+
"@composeDirective" => "directive @composeDirective(name: String!) repeatable on SCHEMA",
|
|
195
|
+
"@authenticated" => "directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM",
|
|
196
|
+
"@requiresScopes" => "directive @requiresScopes(scopes: [[federation__Scope!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM",
|
|
197
|
+
"@policy" => "directive @policy(policies: [[federation__Policy!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM",
|
|
198
|
+
"@link" => "directive @link(url: String!, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA",
|
|
199
|
+
}.freeze
|
|
200
|
+
|
|
201
|
+
# The types those definitions reference — injected only alongside a
|
|
202
|
+
# directive that needs one. `_Any`/`_Entity`/`_Service` (see
|
|
203
|
+
# entity_plumbing) keep their spec-mandated names; these are ours to
|
|
204
|
+
# namespace, so they can't shadow a subgraph's own type.
|
|
205
|
+
SUBGRAPH_HELPER_TYPES = {
|
|
206
|
+
"federation__FieldSet" => "scalar federation__FieldSet",
|
|
207
|
+
"federation__Scope" => "scalar federation__Scope",
|
|
208
|
+
"federation__Policy" => "scalar federation__Policy",
|
|
209
|
+
"link__Import" => "scalar link__Import",
|
|
210
|
+
"link__Purpose" => "enum link__Purpose { SECURITY EXECUTION }",
|
|
211
|
+
}.freeze
|
|
212
|
+
|
|
213
|
+
# A fed-2 subgraph that @links the spec under a namespace — `as: "fed"`,
|
|
214
|
+
# and "federation" is the default — applies every non-imported directive
|
|
215
|
+
# under it: @federation__key rather than @key.
|
|
216
|
+
def self.subgraph_namespace(sdl)
|
|
217
|
+
sdl[/#{SUBGRAPH_LINK}[^)]*\bas:\s*"([^"]+)"/, 1] || "federation"
|
|
218
|
+
end
|
|
219
|
+
private_class_method :subgraph_namespace
|
|
220
|
+
|
|
221
|
+
# Prepend the definitions this subgraph applies but doesn't declare, under
|
|
222
|
+
# whichever name it applies them by. Only the missing ones — a duplicate
|
|
223
|
+
# definition is a hard error in graphql-ruby, and a subgraph spelling out
|
|
224
|
+
# its own @key (fed-1 style, or a differing shape) must win.
|
|
225
|
+
def self.add_subgraph_definitions(sdl)
|
|
226
|
+
defined = GraphQL.parse(sdl).definitions.filter_map do |defn|
|
|
227
|
+
next unless defn.respond_to?(:name)
|
|
228
|
+
|
|
229
|
+
defn.is_a?(GraphQL::Language::Nodes::DirectiveDefinition) ? "@#{defn.name}" : defn.name
|
|
230
|
+
end.to_set
|
|
231
|
+
namespace = subgraph_namespace(sdl)
|
|
232
|
+
|
|
233
|
+
directives = SUBGRAPH_DIRECTIVE_DEFS.filter_map do |name, defn|
|
|
234
|
+
applied = [name, "@#{namespace}__#{name.delete_prefix("@")}"]
|
|
235
|
+
.find { |as| !defined.include?(as) && sdl.match?(/#{as}\b/) }
|
|
236
|
+
applied && defn.sub(name, applied)
|
|
237
|
+
end
|
|
238
|
+
types = SUBGRAPH_HELPER_TYPES
|
|
239
|
+
.select { |name, _| !defined.include?(name) && directives.any? { |defn| defn.include?(name) } }
|
|
240
|
+
.values
|
|
241
|
+
|
|
242
|
+
added = types + directives + entity_plumbing(sdl, namespace, defined)
|
|
243
|
+
added.empty? ? sdl : "#{added.join("\n")}\n\n#{sdl}"
|
|
244
|
+
end
|
|
245
|
+
private_class_method :add_subgraph_definitions
|
|
246
|
+
|
|
247
|
+
# The entity resolver every subgraph serves — and which no subgraph SDL
|
|
248
|
+
# contains: `_service { sdl }` and `rover subgraph fetch` both print the
|
|
249
|
+
# published schema, where the plumbing is implicit. Supply it so an
|
|
250
|
+
# `_entities` query can be typed against the artifact you actually have
|
|
251
|
+
# (a supergraph doesn't describe `_entities` at all). `_Entity` is the
|
|
252
|
+
# union of the file's own @key'd types, so it stays accurate per subgraph.
|
|
253
|
+
# https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/reference/subgraph-spec
|
|
254
|
+
def self.entity_plumbing(sdl, namespace, defined)
|
|
255
|
+
doc = GraphQL.parse(sdl)
|
|
256
|
+
root = query_root_name(doc)
|
|
257
|
+
entities = entity_names(doc, namespace)
|
|
258
|
+
return [] if entities.empty? || root.nil? || defined.include?("_Any")
|
|
60
259
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
260
|
+
[
|
|
261
|
+
"scalar _Any",
|
|
262
|
+
"type _Service { sdl: String }",
|
|
263
|
+
"union _Entity = #{entities.join(" | ")}",
|
|
264
|
+
"extend type #{root} {\n" \
|
|
265
|
+
" _entities(representations: [_Any!]!): [_Entity]!\n" \
|
|
266
|
+
" _service: _Service!\n" \
|
|
267
|
+
"}",
|
|
268
|
+
]
|
|
64
269
|
end
|
|
65
|
-
private_class_method :
|
|
270
|
+
private_class_method :entity_plumbing
|
|
271
|
+
|
|
272
|
+
# The object types this subgraph resolves as entities: the ones it applies
|
|
273
|
+
# @key to, under whichever name it applies it by (@federation__key when the
|
|
274
|
+
# spec is linked under a namespace). Type extensions count — fed-1 spells an
|
|
275
|
+
# entity it doesn't own as `extend type User @key(...)`.
|
|
276
|
+
def self.entity_names(doc, namespace)
|
|
277
|
+
key_names = ["key", "#{namespace}__key"]
|
|
278
|
+
|
|
279
|
+
doc.definitions.filter_map do |defn|
|
|
280
|
+
next unless defn.is_a?(GraphQL::Language::Nodes::ObjectTypeDefinition) ||
|
|
281
|
+
defn.is_a?(GraphQL::Language::Nodes::ObjectTypeExtension)
|
|
282
|
+
|
|
283
|
+
defn.name if defn.directives.any? { |d| key_names.include?(d.name) }
|
|
284
|
+
end.uniq
|
|
285
|
+
end
|
|
286
|
+
private_class_method :entity_names
|
|
287
|
+
|
|
288
|
+
# The query root's name — `schema { query: Root }` when the file says so,
|
|
289
|
+
# `Query` by convention. nil when the file has no query root to extend.
|
|
290
|
+
def self.query_root_name(doc)
|
|
291
|
+
declared = doc.definitions.grep(GraphQL::Language::Nodes::SchemaDefinition).first ||
|
|
292
|
+
doc.definitions.grep(GraphQL::Language::Nodes::SchemaExtension).first
|
|
293
|
+
name = declared&.query || "Query"
|
|
294
|
+
root_present = doc.definitions.any? do |defn|
|
|
295
|
+
defn.respond_to?(:name) && defn.name == name &&
|
|
296
|
+
(defn.is_a?(GraphQL::Language::Nodes::ObjectTypeDefinition) ||
|
|
297
|
+
defn.is_a?(GraphQL::Language::Nodes::ObjectTypeExtension))
|
|
298
|
+
end
|
|
299
|
+
name if root_present
|
|
300
|
+
end
|
|
301
|
+
private_class_method :query_root_name
|
|
302
|
+
|
|
303
|
+
# how a schema declares the specs it's built from: @link in fed 2, @core in fed 1
|
|
304
|
+
LINK_DIRECTIVES = %w[link core].to_set.freeze
|
|
305
|
+
|
|
306
|
+
# The floor a supergraph gets whether it declares the specs or not — a
|
|
307
|
+
# hand-written or trimmed one often has no @link header at all. Derivation
|
|
308
|
+
# only ever adds to this.
|
|
309
|
+
DEFAULT_PREFIXES = %w[join__ link__ core__].freeze
|
|
310
|
+
DEFAULT_DIRECTIVES = %w[link core inaccessible].freeze
|
|
311
|
+
|
|
312
|
+
# What THIS document calls federation's machinery — type-name prefixes,
|
|
313
|
+
# directive names, and the local names @inaccessible answers to — read off
|
|
314
|
+
# its own @link/@core declarations rather than assumed: a linked spec's name
|
|
315
|
+
# gives both a `join__` type prefix and a root `@join` directive, `as:`
|
|
316
|
+
# renames it, and each `import:` entry binds one more directive in the root
|
|
317
|
+
# namespace, possibly under a different local name. A missed @inaccessible
|
|
318
|
+
# rename over-permits the derived API schema, not merely leaks a type.
|
|
319
|
+
# https://specs.apollo.dev/link/v1.0/ · https://specs.apollo.dev/core/v0.2/
|
|
320
|
+
def self.link_namespaces(doc)
|
|
321
|
+
prefixes = DEFAULT_PREFIXES.dup
|
|
322
|
+
directives = DEFAULT_DIRECTIVES.to_set
|
|
323
|
+
inaccessible = Set["inaccessible"]
|
|
324
|
+
|
|
325
|
+
link_declarations(doc).each do |args|
|
|
326
|
+
spec = spec_name(args["url"] || args["feature"])
|
|
327
|
+
local = args["as"].is_a?(String) ? args["as"] : spec
|
|
328
|
+
if local
|
|
329
|
+
prefixes << "#{local}__"
|
|
330
|
+
directives << local
|
|
331
|
+
# a spec's root directive is its own name: @link(url: ".../inaccessible/v0.2", as: "private")
|
|
332
|
+
inaccessible << local if spec == "inaccessible"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
imports(args["import"]).each do |name, as|
|
|
336
|
+
next unless as.start_with?("@")
|
|
337
|
+
|
|
338
|
+
directives << as.delete_prefix("@")
|
|
339
|
+
inaccessible << as.delete_prefix("@") if name == "@inaccessible"
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
{ prefixes: prefixes.uniq.freeze, directives: directives.freeze, inaccessible: inaccessible.freeze }
|
|
344
|
+
end
|
|
345
|
+
private_class_method :link_namespaces
|
|
346
|
+
|
|
347
|
+
# The @link/@core applications on the document's schema definition, each as
|
|
348
|
+
# a plain argument hash.
|
|
349
|
+
def self.link_declarations(doc)
|
|
350
|
+
doc.definitions.flat_map do |defn|
|
|
351
|
+
next [] unless defn.is_a?(GraphQL::Language::Nodes::SchemaDefinition) ||
|
|
352
|
+
defn.is_a?(GraphQL::Language::Nodes::SchemaExtension)
|
|
353
|
+
|
|
354
|
+
defn.directives
|
|
355
|
+
.select { |d| LINK_DIRECTIVES.include?(d.name) }
|
|
356
|
+
.map { |d| d.arguments.to_h { |arg| [arg.name, arg.value] } }
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
private_class_method :link_declarations
|
|
360
|
+
|
|
361
|
+
# `import: ["@key", {name: "@inaccessible", as: "@private"}]` as
|
|
362
|
+
# [spec name, local name] pairs.
|
|
363
|
+
def self.imports(value)
|
|
364
|
+
Array(value).filter_map do |entry|
|
|
365
|
+
case entry
|
|
366
|
+
when String then [entry, entry]
|
|
367
|
+
when GraphQL::Language::Nodes::InputObject
|
|
368
|
+
fields = entry.to_h
|
|
369
|
+
name = fields["name"]
|
|
370
|
+
[name, fields["as"].is_a?(String) ? fields["as"] : name] if name.is_a?(String)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
private_class_method :imports
|
|
375
|
+
|
|
376
|
+
VERSION_TAG = /\Av\d+\.\d+\z/
|
|
377
|
+
GRAPHQL_NAME = /\A[A-Za-z][A-Za-z0-9_]*\z/
|
|
378
|
+
|
|
379
|
+
# The name a linked spec's elements are namespaced under: the URL's
|
|
380
|
+
# penultimate path segment when the last is a version tag, else the last one.
|
|
381
|
+
# Query strings, fragments and empty segments don't count. A segment that
|
|
382
|
+
# can't be a namespace (a bare host, or a name carrying the `__` separator)
|
|
383
|
+
# means the URL is just an opaque identifier and nothing is derived.
|
|
384
|
+
# https://specs.apollo.dev/link/v1.0/
|
|
385
|
+
def self.spec_name(url)
|
|
386
|
+
return unless url.is_a?(String)
|
|
387
|
+
|
|
388
|
+
segments = url.split(/[?#]/).first.to_s.split("/").reject(&:empty?)
|
|
389
|
+
segments.pop if segments.last&.match?(VERSION_TAG)
|
|
390
|
+
name = segments.last
|
|
391
|
+
name if name&.match?(GRAPHQL_NAME) && !name.include?("__") && !name.end_with?("_")
|
|
392
|
+
end
|
|
393
|
+
private_class_method :spec_name
|
|
394
|
+
|
|
395
|
+
# Synthetic composition TYPES are always prefixed (join__Graph, link__Import).
|
|
396
|
+
# The bare names (link/core/inaccessible) are DIRECTIVES only — a user type
|
|
397
|
+
# literally named `link` (Hasura-style lowercase) must not be dropped.
|
|
398
|
+
def self.federation_type_name?(name, ns)
|
|
399
|
+
!!name && name.start_with?(*ns[:prefixes])
|
|
400
|
+
end
|
|
401
|
+
private_class_method :federation_type_name?
|
|
402
|
+
|
|
403
|
+
def self.federation_directive_name?(name, ns)
|
|
404
|
+
!!name && (name.start_with?(*ns[:prefixes]) || ns[:directives].include?(name))
|
|
405
|
+
end
|
|
406
|
+
private_class_method :federation_directive_name?
|
|
66
407
|
|
|
67
408
|
# Drop the composition machinery from supergraph SDL: the synthetic
|
|
68
409
|
# join__*/link__* type and directive definitions, and every @join__*/@link
|
|
@@ -73,12 +414,33 @@ module GraphWeaver::SchemaLoader
|
|
|
73
414
|
# and no join__* leaking into schema.types.
|
|
74
415
|
def self.strip_federation(sdl)
|
|
75
416
|
doc = GraphQL.parse(sdl)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
.
|
|
417
|
+
ns = link_namespaces(doc)
|
|
418
|
+
defs = remove_inaccessible(doc.definitions, ns)
|
|
419
|
+
.reject { |defn| federation_definition?(defn, ns) }
|
|
420
|
+
.map { |defn| strip_federation_directives(defn, ns) }
|
|
421
|
+
|
|
422
|
+
if defs.none? { |d| d.is_a?(GraphQL::Language::Nodes::ObjectTypeDefinition) }
|
|
423
|
+
raise GraphWeaver::Error,
|
|
424
|
+
"supergraph has no object types left after stripping the federation machinery — " \
|
|
425
|
+
"is the whole schema behind @inaccessible?"
|
|
426
|
+
end
|
|
427
|
+
|
|
79
428
|
GraphQL::Language::Nodes::Document.new(definitions: defs).to_query_string
|
|
80
429
|
end
|
|
81
430
|
|
|
431
|
+
# a synthetic composition definition to drop: a federation directive
|
|
432
|
+
# definition (by name), or a synthetic join__*/link__* type (by prefix)
|
|
433
|
+
def self.federation_definition?(defn, ns)
|
|
434
|
+
return false unless defn.respond_to?(:name)
|
|
435
|
+
|
|
436
|
+
if defn.is_a?(GraphQL::Language::Nodes::DirectiveDefinition)
|
|
437
|
+
federation_directive_name?(defn.name, ns)
|
|
438
|
+
else
|
|
439
|
+
federation_type_name?(defn.name, ns)
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
private_class_method :federation_definition?
|
|
443
|
+
|
|
82
444
|
# Derive the API schema by dropping every element marked @inaccessible —
|
|
83
445
|
# present in the federated graph but hidden from the public API the router
|
|
84
446
|
# serves (its common use is safely rolling out a field on a shared type).
|
|
@@ -87,14 +449,15 @@ module GraphWeaver::SchemaLoader
|
|
|
87
449
|
# removed — repeated to a fixpoint. So codegen matches exactly what clients
|
|
88
450
|
# can query, without the over-permitting a raw supergraph would allow and
|
|
89
451
|
# without Apollo's JS tooling to subtract the API schema.
|
|
90
|
-
def self.remove_inaccessible(definitions)
|
|
91
|
-
removed = definitions.select { |d| type_definition?(d) && inaccessible?(d) }.map(&:name).to_set
|
|
452
|
+
def self.remove_inaccessible(definitions, ns)
|
|
453
|
+
removed = definitions.select { |d| type_definition?(d) && inaccessible?(d, ns) }.map(&:name).to_set
|
|
92
454
|
loop do
|
|
93
455
|
survivors = definitions
|
|
94
456
|
.reject { |d| type_definition?(d) && removed.include?(d.name) }
|
|
95
|
-
.map { |d| prune_inaccessible(d, removed) }
|
|
457
|
+
.map { |d| prune_inaccessible(d, removed, ns) }
|
|
458
|
+
# survivors already exclude `removed`, so anything newly emptied is fresh
|
|
96
459
|
newly = survivors.select { |d| type_definition?(d) && type_emptied?(d) }.map(&:name)
|
|
97
|
-
return survivors if
|
|
460
|
+
return survivors if newly.empty?
|
|
98
461
|
|
|
99
462
|
removed.merge(newly)
|
|
100
463
|
end
|
|
@@ -110,8 +473,9 @@ module GraphWeaver::SchemaLoader
|
|
|
110
473
|
end
|
|
111
474
|
private_class_method :type_definition?
|
|
112
475
|
|
|
113
|
-
|
|
114
|
-
|
|
476
|
+
# @inaccessible under whatever local name the schema's @link bound it to.
|
|
477
|
+
def self.inaccessible?(node, ns)
|
|
478
|
+
node.respond_to?(:directives) && node.directives.any? { |d| ns[:inaccessible].include?(d.name) }
|
|
115
479
|
end
|
|
116
480
|
private_class_method :inaccessible?
|
|
117
481
|
|
|
@@ -136,9 +500,9 @@ module GraphWeaver::SchemaLoader
|
|
|
136
500
|
# Remove @inaccessible children and children referencing a removed type,
|
|
137
501
|
# from a type's fields (and their arguments), enum values, union members,
|
|
138
502
|
# and implemented interfaces.
|
|
139
|
-
def self.prune_inaccessible(node, removed)
|
|
503
|
+
def self.prune_inaccessible(node, removed, ns)
|
|
140
504
|
gone = lambda do |child|
|
|
141
|
-
inaccessible?(child) || (child.respond_to?(:type) && removed.include?(unwrapped_type_name(child)))
|
|
505
|
+
inaccessible?(child, ns) || (child.respond_to?(:type) && removed.include?(unwrapped_type_name(child)))
|
|
142
506
|
end
|
|
143
507
|
|
|
144
508
|
changes = {}
|
|
@@ -148,6 +512,10 @@ module GraphWeaver::SchemaLoader
|
|
|
148
512
|
args && args.any? ? field.merge(arguments: args.reject(&gone)) : field
|
|
149
513
|
end
|
|
150
514
|
end
|
|
515
|
+
# a directive definition's own arguments — the only top-level node with
|
|
516
|
+
# them, and Apollo's REFERENCED_INACCESSIBLE rule should already have
|
|
517
|
+
# rejected such a supergraph; belt and braces for hand-written ones
|
|
518
|
+
changes[:arguments] = node.arguments.reject(&gone) if node.respond_to?(:arguments) && node.arguments
|
|
151
519
|
changes[:values] = node.values.reject(&gone) if node.respond_to?(:values) && node.values
|
|
152
520
|
changes[:types] = node.types.reject { |t| removed.include?(t.name) } if node.respond_to?(:types) && node.types
|
|
153
521
|
if node.respond_to?(:interfaces) && node.interfaces
|
|
@@ -159,14 +527,23 @@ module GraphWeaver::SchemaLoader
|
|
|
159
527
|
|
|
160
528
|
# Recursively remove @join__*/@link applications from a definition and its
|
|
161
529
|
# fields, arguments, and enum values.
|
|
162
|
-
def self.strip_federation_directives(node)
|
|
530
|
+
def self.strip_federation_directives(node, ns)
|
|
163
531
|
changes = {}
|
|
164
532
|
if node.respond_to?(:directives) && node.directives
|
|
165
|
-
|
|
533
|
+
# A schema definition keeps NONE: graphql-ruby's printer omits the
|
|
534
|
+
# `{ query: Query }` body when the root type names are conventional but
|
|
535
|
+
# still prints directives, so any survivor (@tag, @composeDirective, a
|
|
536
|
+
# composed custom one) reprints as an unparseable braceless `schema @tag`.
|
|
537
|
+
# Codegen never reads schema directives.
|
|
538
|
+
changes[:directives] = if node.is_a?(GraphQL::Language::Nodes::SchemaDefinition)
|
|
539
|
+
[]
|
|
540
|
+
else
|
|
541
|
+
node.directives.reject { |d| federation_directive_name?(d.name, ns) }
|
|
542
|
+
end
|
|
166
543
|
end
|
|
167
|
-
changes[:fields] = node.fields.map { |c| strip_federation_directives(c) } if node.respond_to?(:fields) && node.fields
|
|
168
|
-
changes[:arguments] = node.arguments.map { |c| strip_federation_directives(c) } if node.respond_to?(:arguments) && node.arguments
|
|
169
|
-
changes[:values] = node.values.map { |c| strip_federation_directives(c) } if node.respond_to?(:values) && node.values
|
|
544
|
+
changes[:fields] = node.fields.map { |c| strip_federation_directives(c, ns) } if node.respond_to?(:fields) && node.fields
|
|
545
|
+
changes[:arguments] = node.arguments.map { |c| strip_federation_directives(c, ns) } if node.respond_to?(:arguments) && node.arguments
|
|
546
|
+
changes[:values] = node.values.map { |c| strip_federation_directives(c, ns) } if node.respond_to?(:values) && node.values
|
|
170
547
|
changes.empty? ? node : node.merge(changes)
|
|
171
548
|
end
|
|
172
549
|
private_class_method :strip_federation_directives
|
|
@@ -184,7 +561,10 @@ module GraphWeaver::SchemaLoader
|
|
|
184
561
|
# reads (its extension picks the format)
|
|
185
562
|
# - a path — the extension picks the format: .json is the verbatim
|
|
186
563
|
# introspection result, .graphql/.gql is SDL (human-readable,
|
|
187
|
-
# PR-reviewable diffs)
|
|
564
|
+
# PR-reviewable diffs). Both load back to the same schema, but not
|
|
565
|
+
# at the same cost: SDL has to be parsed, so on a big schema
|
|
566
|
+
# (GitHub's 2.9 MB) it's roughly twice as slow to load — SDL for
|
|
567
|
+
# reviewability, JSON when boot time matters.
|
|
188
568
|
# - :json / :graphql / :gql — GraphWeaver.schema_path's location, in
|
|
189
569
|
# that format
|
|
190
570
|
# Reading is format-agnostic: any fresh sibling dump counts, whatever
|
|
@@ -202,7 +582,7 @@ module GraphWeaver::SchemaLoader
|
|
|
202
582
|
# GraphWeaver::SchemaLoader.introspect(transport).to_json
|
|
203
583
|
# end
|
|
204
584
|
# schema = GraphWeaver::SchemaLoader.load(json)
|
|
205
|
-
def self.introspect(transport, cache: nil, ttl: nil)
|
|
585
|
+
def self.introspect(transport, cache: nil, ttl: nil, auth_env: nil)
|
|
206
586
|
cache = cache_path(cache)
|
|
207
587
|
|
|
208
588
|
if cache
|
|
@@ -218,12 +598,21 @@ module GraphWeaver::SchemaLoader
|
|
|
218
598
|
GraphWeaver.log(:info) { "schema cache miss: #{cache}" }
|
|
219
599
|
end
|
|
220
600
|
|
|
221
|
-
result = GraphWeaver.log_timed(:info, "introspected #{transport
|
|
601
|
+
result = GraphWeaver.log_timed(:info, "introspected #{endpoint(transport)}") do
|
|
222
602
|
transport.execute(GraphQL::Introspection.query, variables: {}).to_h
|
|
223
603
|
end
|
|
224
604
|
if (errors = result["errors"])
|
|
225
605
|
raise GraphWeaver::Error, "introspection failed: #{errors.inspect}"
|
|
226
606
|
end
|
|
607
|
+
# a 200 of well-formed JSON that isn't an introspection result — a REST
|
|
608
|
+
# base url, a GraphiQL page, a proxy that ate the path. from_introspection
|
|
609
|
+
# would raise a bare NoMethodError on the missing "__schema" key.
|
|
610
|
+
data = result["data"]
|
|
611
|
+
unless data.is_a?(Hash) && data["__schema"]
|
|
612
|
+
raise GraphWeaver::Error,
|
|
613
|
+
"introspection at #{endpoint(transport)} returned no __schema — is that a GraphQL " \
|
|
614
|
+
"endpoint? got: #{result.inspect[0, 200]}"
|
|
615
|
+
end
|
|
227
616
|
|
|
228
617
|
schema = GraphQL::Schema.from_introspection(result)
|
|
229
618
|
|
|
@@ -232,7 +621,7 @@ module GraphWeaver::SchemaLoader
|
|
|
232
621
|
# the extension picks the format: .json is the verbatim wire
|
|
233
622
|
# artifact; .graphql/.gql is SDL — human-readable, PR-reviewable
|
|
234
623
|
# diffs (both generate byte-identical code)
|
|
235
|
-
meta = stamp(transport)
|
|
624
|
+
meta = stamp(transport, auth_env)
|
|
236
625
|
content = if cache.end_with?(".json")
|
|
237
626
|
JSON.generate(meta ? result.merge("graph_weaver" => meta) : result)
|
|
238
627
|
else
|
|
@@ -246,6 +635,13 @@ module GraphWeaver::SchemaLoader
|
|
|
246
635
|
schema
|
|
247
636
|
end
|
|
248
637
|
|
|
638
|
+
# What to call the thing we introspected, for a log line or an error: its
|
|
639
|
+
# url when it has one, else the class (a schema class, a fake).
|
|
640
|
+
def self.endpoint(transport)
|
|
641
|
+
(transport.respond_to?(:url) && transport.url) || transport.class
|
|
642
|
+
end
|
|
643
|
+
private_class_method :endpoint
|
|
644
|
+
|
|
249
645
|
# The conventional schema dump, whatever its format: schema_path or the
|
|
250
646
|
# first sibling extension that exists. nil when none is on disk.
|
|
251
647
|
def self.locate_path(path = GraphWeaver.schema_path)
|
|
@@ -258,8 +654,13 @@ module GraphWeaver::SchemaLoader
|
|
|
258
654
|
found && load(found)
|
|
259
655
|
end
|
|
260
656
|
|
|
657
|
+
# The ENV var holding the token for a private API, when the dump doesn't
|
|
658
|
+
# name its own.
|
|
659
|
+
DEFAULT_AUTH_ENV = "GRAPHWEAVER_AUTH"
|
|
660
|
+
|
|
261
661
|
# The provenance recorded in a dump ({"url" => ..., "introspected_at"
|
|
262
|
-
# => ...}), whichever format holds it; nil for
|
|
662
|
+
# => ..., "auth_env" => ...}), whichever format holds it; nil for
|
|
663
|
+
# local/unannotated dumps.
|
|
263
664
|
def self.provenance(path)
|
|
264
665
|
content = File.read(path)
|
|
265
666
|
if path.end_with?(".json")
|
|
@@ -269,10 +670,19 @@ module GraphWeaver::SchemaLoader
|
|
|
269
670
|
end
|
|
270
671
|
end
|
|
271
672
|
|
|
673
|
+
# The ENV var a dump's token lives in — whichever the generator recorded,
|
|
674
|
+
# else GRAPHWEAVER_AUTH. Keeps `--auth MY_TOKEN` from producing an app
|
|
675
|
+
# that authenticates and rake tasks that 401.
|
|
676
|
+
def self.auth_env(path = nil)
|
|
677
|
+
# the first refresh names a dump that doesn't exist yet
|
|
678
|
+
recorded = provenance(path)&.dig("auth_env") if path && File.exist?(path)
|
|
679
|
+
recorded || DEFAULT_AUTH_ENV
|
|
680
|
+
end
|
|
681
|
+
|
|
272
682
|
# Re-introspect a dump's source and compare — true when the server has
|
|
273
683
|
# drifted from what's on disk. transport: overrides the transport (auth
|
|
274
684
|
# etc); by default one is built from the dump's recorded url. Wired up
|
|
275
|
-
# as `rake graph_weaver:schema:
|
|
685
|
+
# as `rake graph_weaver:schema:diff` / `:refresh`.
|
|
276
686
|
def self.stale?(path, transport: nil)
|
|
277
687
|
transport ||= source_transport(path)
|
|
278
688
|
fresh = introspect(transport)
|
|
@@ -280,15 +690,45 @@ module GraphWeaver::SchemaLoader
|
|
|
280
690
|
fresh.to_definition != load(path).to_definition
|
|
281
691
|
end
|
|
282
692
|
|
|
283
|
-
#
|
|
284
|
-
#
|
|
693
|
+
# Re-introspect and rewrite the local dump, returning [path, url].
|
|
694
|
+
# url: defaults to the one the dump recorded, so a refresh needs no
|
|
695
|
+
# arguments once a dump exists — and passing one bootstraps the first
|
|
696
|
+
# dump, which is what `rails g graph_weaver:install` does.
|
|
697
|
+
# auth_env: the ENV var holding the token — defaults to whichever the
|
|
698
|
+
# dump recorded, so `--auth MY_TOKEN` keeps working on every later
|
|
699
|
+
# refresh without being repeated. auth: passes a token directly.
|
|
700
|
+
def self.refresh!(url: nil, auth_env: nil, auth: nil)
|
|
701
|
+
path = locate_path
|
|
702
|
+
url ||= path && provenance(path)&.dig("url")
|
|
703
|
+
raise GraphWeaver::Error, refresh_hint(path) unless url
|
|
704
|
+
|
|
705
|
+
path ||= GraphWeaver.schema_path
|
|
706
|
+
auth_env ||= self.auth_env(path)
|
|
707
|
+
auth ||= ENV[auth_env]
|
|
708
|
+
# ttl: 0 — an existing dump never counts as fresh, a refresh always refetches
|
|
709
|
+
introspect(GraphWeaver.new(url, auth:).transport, cache: path, ttl: 0, auth_env:)
|
|
710
|
+
[path, url]
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
def self.refresh_hint(path)
|
|
714
|
+
missing = path ? "#{path} records no source url" : "no schema dump at #{GraphWeaver.schema_path}"
|
|
715
|
+
"#{missing} — pass one: rake graph_weaver:schema:refresh URL=https://api.example.com/graphql " \
|
|
716
|
+
"(a dump taken from a schema class is rebuilt from code, not re-fetched — see " \
|
|
717
|
+
"docs/getting_started.md#your-apps-own-schema-in-process)"
|
|
718
|
+
end
|
|
719
|
+
private_class_method :refresh_hint
|
|
720
|
+
|
|
721
|
+
# a transport to the dump's recorded url, authenticated from whichever
|
|
722
|
+
# ENV var the dump named (else GRAPHWEAVER_AUTH)
|
|
285
723
|
def self.source_transport(path)
|
|
286
724
|
meta = provenance(path)
|
|
287
725
|
unless meta&.key?("url")
|
|
288
|
-
raise GraphWeaver::Error,
|
|
726
|
+
raise GraphWeaver::Error,
|
|
727
|
+
"#{path} records no source url — it wasn't introspected from one. Pass transport:, " \
|
|
728
|
+
"or rebuild it from the schema class that produced it."
|
|
289
729
|
end
|
|
290
730
|
|
|
291
|
-
GraphWeaver.new(meta["url"], auth: ENV[
|
|
731
|
+
GraphWeaver.new(meta["url"], auth: ENV[auth_env(path)]).transport
|
|
292
732
|
end
|
|
293
733
|
private_class_method :source_transport
|
|
294
734
|
|
|
@@ -297,11 +737,15 @@ module GraphWeaver::SchemaLoader
|
|
|
297
737
|
# "graph_weaver" sibling key in introspection JSON (from_introspection
|
|
298
738
|
# reads only "data"). nil when the transport has no url (schema
|
|
299
739
|
# classes, fakes).
|
|
300
|
-
def self.stamp(transport)
|
|
740
|
+
def self.stamp(transport, auth_env = nil)
|
|
301
741
|
return unless transport.respond_to?(:url) && transport.url
|
|
302
742
|
|
|
303
743
|
require "time"
|
|
304
|
-
{ "url" => transport.url, "introspected_at" => Time.now.utc.iso8601 }
|
|
744
|
+
meta = { "url" => transport.url, "introspected_at" => Time.now.utc.iso8601 }
|
|
745
|
+
# only the non-default var is worth recording — auth_env falls back to
|
|
746
|
+
# DEFAULT_AUTH_ENV, so an unannotated dump reads the same either way
|
|
747
|
+
meta["auth_env"] = auth_env if auth_env && auth_env != DEFAULT_AUTH_ENV
|
|
748
|
+
meta
|
|
305
749
|
end
|
|
306
750
|
private_class_method :stamp
|
|
307
751
|
|
|
@@ -349,4 +793,323 @@ module GraphWeaver::SchemaLoader
|
|
|
349
793
|
File.exist?(path) && (ttl.nil? || Time.now - File.mtime(path) < ttl)
|
|
350
794
|
end
|
|
351
795
|
private_class_method :fresh?
|
|
796
|
+
|
|
797
|
+
# The other half of a supergraph. `load` strips the @join__* machinery to
|
|
798
|
+
# get the API schema; this keeps it — who resolves what:
|
|
799
|
+
#
|
|
800
|
+
# table = GraphWeaver::SchemaLoader.routing_table("supergraph.graphql")
|
|
801
|
+
# table.owners("Product", "shippingEstimate") # => ["reviews"]
|
|
802
|
+
# table.keys("Product", "products") # => [["upc"]]
|
|
803
|
+
#
|
|
804
|
+
# Takes the same sources `load` does, minus an introspection result — a
|
|
805
|
+
# router's introspection answers with the API schema, which by design says
|
|
806
|
+
# nothing about subgraphs.
|
|
807
|
+
def self.routing_table(source)
|
|
808
|
+
sdl = supergraph_sdl(source)
|
|
809
|
+
unless federation_sdl?(sdl)
|
|
810
|
+
raise GraphWeaver::Error,
|
|
811
|
+
"no routing table here — a composed supergraph SDL carries one in its @join__* markers, " \
|
|
812
|
+
"and this schema has none (a subgraph or an API schema describes one service's slice, " \
|
|
813
|
+
"not who resolves what)"
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
RoutingTable.new(sdl)
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
def self.supergraph_sdl(source)
|
|
820
|
+
if source.is_a?(Hash)
|
|
821
|
+
raise GraphWeaver::Error,
|
|
822
|
+
"an introspection result carries no routing table — introspection answers with the API " \
|
|
823
|
+
"schema. Load the composed supergraph SDL instead."
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
text = source.to_s
|
|
827
|
+
sdl_content?(text) ? text : read_schema(text)
|
|
828
|
+
end
|
|
829
|
+
private_class_method :supergraph_sdl
|
|
830
|
+
|
|
831
|
+
# Which subgraph resolves what, read off a composed supergraph's @join__*
|
|
832
|
+
# directives. Subgraphs are named the way `@join__graph(name:)` names them —
|
|
833
|
+
# the same strings a router config and `rover` use — not the SDL's uppercase
|
|
834
|
+
# enum spelling.
|
|
835
|
+
#
|
|
836
|
+
# Everything here is a plain read of the artifact. It cannot plan a query;
|
|
837
|
+
# it answers the questions a planner (or an error message) asks.
|
|
838
|
+
class RoutingTable
|
|
839
|
+
# One field's routing: `graphs` resolve it, `external` declare it without
|
|
840
|
+
# resolving it (an @external copy exists so that subgraph can @key or
|
|
841
|
+
# @requires on it), and requires/provides/override carry the field sets and
|
|
842
|
+
# the migration marker verbatim.
|
|
843
|
+
Field = Struct.new(:graphs, :external, :requires, :provides, :override)
|
|
844
|
+
|
|
845
|
+
# Every @join__ directive this table understands. One it doesn't is a
|
|
846
|
+
# federation construct nobody has taught it to read, and it lands in
|
|
847
|
+
# `unsupported` rather than being skipped — a routing table that silently
|
|
848
|
+
# ignores half a spec version routes confidently and wrongly.
|
|
849
|
+
KNOWN = %w[
|
|
850
|
+
join__type join__field join__graph join__implements
|
|
851
|
+
join__unionMember join__enumValue join__owner
|
|
852
|
+
].to_set.freeze
|
|
853
|
+
|
|
854
|
+
# every subgraph in the graph, in the order the supergraph declares them
|
|
855
|
+
attr_reader :subgraphs
|
|
856
|
+
|
|
857
|
+
# constructs found in this supergraph that the table can't describe
|
|
858
|
+
# faithfully, each as a one-line explanation
|
|
859
|
+
attr_reader :unsupported
|
|
860
|
+
|
|
861
|
+
# { "Media" => ["catalog"] } — types a subgraph resolves as an
|
|
862
|
+
# @interfaceObject, so it answers a whole interface's implementations and
|
|
863
|
+
# this table can't attribute their fields subgraph by subgraph. Not in
|
|
864
|
+
# `unsupported` because it is a fact about one type rather than about the
|
|
865
|
+
# table: a query that never reaches the type is unaffected, and only a
|
|
866
|
+
# caller planning one can tell.
|
|
867
|
+
attr_reader :interface_objects
|
|
868
|
+
|
|
869
|
+
def initialize(sdl)
|
|
870
|
+
@document = GraphQL.parse(sdl)
|
|
871
|
+
@names = {} # "ACCOUNTS" => "accounts"
|
|
872
|
+
@declared_in = {} # "User" => ["accounts", "reviews"]
|
|
873
|
+
@keys = {} # "User" => { "accounts" => [["id"]] }
|
|
874
|
+
@fields = {} # "User" => { "reviews" => Field }
|
|
875
|
+
@field_names = {} # "User" => Set["id", "username"]
|
|
876
|
+
@abstract = {} # "FeedItem" => ["Announcement", "Review"]
|
|
877
|
+
@possible = {} # "FeedItem" => { "reviews" => ["Announcement", "Review"] }
|
|
878
|
+
@unsupported = []
|
|
879
|
+
@interface_objects = {}
|
|
880
|
+
|
|
881
|
+
read_graphs
|
|
882
|
+
read_abstracts
|
|
883
|
+
read_types
|
|
884
|
+
@subgraphs = @names.values.freeze
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
# Which subgraphs can resolve Type.field, by name. A field with no
|
|
888
|
+
# @join__field at all lives wherever its type does — the composer omits
|
|
889
|
+
# the directive when it has nothing to say, and that omission is the
|
|
890
|
+
# supergraph spec's way of saying "everywhere".
|
|
891
|
+
def owners(type_name, field_name)
|
|
892
|
+
field = self.field(type_name, field_name)
|
|
893
|
+
return declared_in(type_name) if field.nil?
|
|
894
|
+
return field.graphs if field.graphs.any?
|
|
895
|
+
|
|
896
|
+
# declared only as @external/@usedOverridden: a reference, not a resolver
|
|
897
|
+
field.external.any? ? [] : declared_in(type_name)
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
# The routing for Type.field, or nil when the supergraph says nothing
|
|
901
|
+
# about it (see owners).
|
|
902
|
+
def field(type_name, field_name) = @fields.dig(type_name, field_name)
|
|
903
|
+
|
|
904
|
+
# which subgraphs declare a type, by name
|
|
905
|
+
def declared_in(type_name) = @declared_in[type_name] || []
|
|
906
|
+
|
|
907
|
+
# every type the supergraph places, in the order it declares them —
|
|
908
|
+
# owners/declared_in answer questions about one, this is the list
|
|
909
|
+
def types = @declared_in.keys
|
|
910
|
+
|
|
911
|
+
# The fields of a type the supergraph routes explicitly. A field with no
|
|
912
|
+
# @join__field isn't here: it lives wherever its type does, so the
|
|
913
|
+
# supergraph names no subgraph for it (see owners).
|
|
914
|
+
def fields(type_name) = (@fields[type_name] || {}).keys
|
|
915
|
+
|
|
916
|
+
# Every field the supergraph's type declares, routed or not — `fields`
|
|
917
|
+
# answers which of them it routes explicitly, this answers what is
|
|
918
|
+
# there.
|
|
919
|
+
def declared_fields(type_name) = @field_names[type_name]&.to_a || []
|
|
920
|
+
|
|
921
|
+
# Whether the supergraph carries this coordinate at all — a type, or a
|
|
922
|
+
# field on it. `owners`/`fields` answer who resolves what the supergraph
|
|
923
|
+
# has; this answers whether it has it, which is the question a local
|
|
924
|
+
# schema's extra field poses.
|
|
925
|
+
def declares?(type_name, field_name = nil)
|
|
926
|
+
return @declared_in.key?(type_name) if field_name.nil?
|
|
927
|
+
|
|
928
|
+
!!@field_names[type_name]&.include?(field_name)
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
# Who to name in an error about a coordinate: the subgraphs that resolve
|
|
932
|
+
# Type.field, or — for a bare type, or a field the supergraph no longer
|
|
933
|
+
# carries — the ones that declare the type. Empty when it places
|
|
934
|
+
# neither, so a message can simply say nothing.
|
|
935
|
+
def responsible(type_name, field_name = nil)
|
|
936
|
+
return declared_in(type_name) unless field_name && declares?(type_name, field_name)
|
|
937
|
+
|
|
938
|
+
owners(type_name, field_name)
|
|
939
|
+
end
|
|
940
|
+
|
|
941
|
+
# The @key field sets a subgraph will answer an `_entities` fetch on, each
|
|
942
|
+
# as a list of dotted paths ("id organization { id }" => ["id",
|
|
943
|
+
# "organization.id"]). A `resolvable: false` key declares a shape this
|
|
944
|
+
# subgraph does not answer for, so it isn't one.
|
|
945
|
+
def keys(type_name, subgraph) = @keys.dig(type_name, subgraph) || []
|
|
946
|
+
|
|
947
|
+
# whether any subgraph will resolve this type from a key
|
|
948
|
+
def entity?(type_name) = (@keys[type_name] || {}).each_value.any?(&:any?)
|
|
949
|
+
|
|
950
|
+
# The concrete types `subgraph` can answer an abstract type with — a
|
|
951
|
+
# union's members there, an interface's implementations there. Only these
|
|
952
|
+
# may be named in a fetch to it: a subgraph rejects an `... on T` its own
|
|
953
|
+
# schema doesn't place in the abstract type.
|
|
954
|
+
#
|
|
955
|
+
# nil when the supergraph doesn't say, which a caller has to treat as
|
|
956
|
+
# unknown rather than empty. @join__unionMember / @join__implements record
|
|
957
|
+
# it; a supergraph composed before those existed carries neither, and then
|
|
958
|
+
# only a type declared in ONE subgraph is answerable — everything it can
|
|
959
|
+
# possibly return is that subgraph's.
|
|
960
|
+
def possible_types(type_name, subgraph)
|
|
961
|
+
all = @abstract[type_name] or return
|
|
962
|
+
|
|
963
|
+
per_graph = @possible[type_name]
|
|
964
|
+
return per_graph[subgraph] || [] if per_graph
|
|
965
|
+
|
|
966
|
+
all if declared_in(type_name).one?
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
def inspect = "#<#{self.class.name} subgraphs=#{@subgraphs.inspect}>"
|
|
970
|
+
alias to_s inspect
|
|
971
|
+
|
|
972
|
+
# A @key/@requires/@provides field set is a selection set. Flattened to
|
|
973
|
+
# dotted paths, so a nested one is recognizable as nested by its shape.
|
|
974
|
+
def self.parse_field_set(text)
|
|
975
|
+
flatten(GraphQL.parse("{ #{text} }").definitions.first.selections, [])
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
def self.flatten(selections, prefix)
|
|
979
|
+
selections.flat_map do |node|
|
|
980
|
+
unless node.is_a?(GraphQL::Language::Nodes::Field)
|
|
981
|
+
raise GraphWeaver::Error, "a field set holds plain fields only, got #{node.class}"
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
if node.selections.any?
|
|
985
|
+
flatten(node.selections, prefix + [node.name])
|
|
986
|
+
else
|
|
987
|
+
[(prefix + [node.name]).join(".")]
|
|
988
|
+
end
|
|
989
|
+
end
|
|
990
|
+
end
|
|
991
|
+
private_class_method :flatten
|
|
992
|
+
|
|
993
|
+
private
|
|
994
|
+
|
|
995
|
+
# join__Graph's enum values ARE the subgraphs: ACCOUNTS
|
|
996
|
+
# @join__graph(name: "accounts", url: "...").
|
|
997
|
+
def read_graphs
|
|
998
|
+
enum = @document.definitions.find do |defn|
|
|
999
|
+
defn.is_a?(GraphQL::Language::Nodes::EnumTypeDefinition) && defn.name == "join__Graph"
|
|
1000
|
+
end
|
|
1001
|
+
return unless enum
|
|
1002
|
+
|
|
1003
|
+
enum.values.each do |value|
|
|
1004
|
+
name = argument(value.directives.find { |d| d.name == "join__graph" }, "name")
|
|
1005
|
+
@names[value.name] = name if name
|
|
1006
|
+
end
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
# What each abstract type can be, from the SDL alone — a union's members,
|
|
1010
|
+
# and the objects that name an interface in their `implements`. The
|
|
1011
|
+
# per-subgraph split comes from @join__ directives in read_types; this is
|
|
1012
|
+
# the whole of it, which is all a single-subgraph abstract type needs.
|
|
1013
|
+
def read_abstracts
|
|
1014
|
+
@document.definitions.each do |defn|
|
|
1015
|
+
case defn
|
|
1016
|
+
when GraphQL::Language::Nodes::UnionTypeDefinition
|
|
1017
|
+
@abstract[defn.name] = defn.types.map(&:name)
|
|
1018
|
+
when GraphQL::Language::Nodes::InterfaceTypeDefinition
|
|
1019
|
+
@abstract[defn.name] ||= []
|
|
1020
|
+
when GraphQL::Language::Nodes::ObjectTypeDefinition
|
|
1021
|
+
defn.interfaces.each { |iface| (@abstract[iface.name] ||= []) << defn.name }
|
|
1022
|
+
end
|
|
1023
|
+
end
|
|
1024
|
+
end
|
|
1025
|
+
|
|
1026
|
+
# An interface implemented by another interface isn't a possible type —
|
|
1027
|
+
# only the objects underneath it are, and each names the interface itself.
|
|
1028
|
+
def read_possible(defn)
|
|
1029
|
+
defn.directives.each do |directive|
|
|
1030
|
+
name = subgraph(directive) or next
|
|
1031
|
+
case directive.name
|
|
1032
|
+
when "join__unionMember"
|
|
1033
|
+
member = argument(directive, "member") or next
|
|
1034
|
+
((@possible[defn.name] ||= {})[name] ||= []) << member
|
|
1035
|
+
when "join__implements"
|
|
1036
|
+
next unless defn.is_a?(GraphQL::Language::Nodes::ObjectTypeDefinition)
|
|
1037
|
+
|
|
1038
|
+
iface = argument(directive, "interface") or next
|
|
1039
|
+
((@possible[iface] ||= {})[name] ||= []) << defn.name
|
|
1040
|
+
end
|
|
1041
|
+
end
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
def read_types
|
|
1045
|
+
@document.definitions.each do |defn|
|
|
1046
|
+
note_unknown(defn, defn.respond_to?(:name) ? defn.name : "schema")
|
|
1047
|
+
next unless defn.respond_to?(:name) && defn.respond_to?(:directives)
|
|
1048
|
+
next if defn.name.start_with?("join__", "link__", "core__")
|
|
1049
|
+
|
|
1050
|
+
joins = defn.directives.select { |d| d.name == "join__type" }
|
|
1051
|
+
@declared_in[defn.name] = joins.filter_map { |d| subgraph(d) }.uniq
|
|
1052
|
+
@keys[defn.name] = read_keys(joins)
|
|
1053
|
+
read_possible(defn)
|
|
1054
|
+
|
|
1055
|
+
as_object = joins.select { |d| argument(d, "isInterfaceObject") == true }
|
|
1056
|
+
@interface_objects[defn.name] = as_object.filter_map { |d| subgraph(d) } if as_object.any?
|
|
1057
|
+
|
|
1058
|
+
read_fields(defn)
|
|
1059
|
+
end
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
def read_keys(joins)
|
|
1063
|
+
joins.each_with_object({}) do |directive, acc|
|
|
1064
|
+
name = subgraph(directive) or next
|
|
1065
|
+
acc[name] ||= []
|
|
1066
|
+
key = argument(directive, "key")
|
|
1067
|
+
acc[name] << self.class.parse_field_set(key) if key && argument(directive, "resolvable") != false
|
|
1068
|
+
end
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
def read_fields(defn)
|
|
1072
|
+
return unless defn.respond_to?(:fields) && defn.fields
|
|
1073
|
+
|
|
1074
|
+
@field_names[defn.name] = defn.fields.map(&:name).to_set
|
|
1075
|
+
@fields[defn.name] = defn.fields.filter_map do |field|
|
|
1076
|
+
note_unknown(field, "#{defn.name}.#{field.name}")
|
|
1077
|
+
applied = field.directives.select { |d| d.name == "join__field" }
|
|
1078
|
+
next if applied.empty? # the supergraph says nothing — see owners
|
|
1079
|
+
|
|
1080
|
+
external, resolvable = applied.partition do |d|
|
|
1081
|
+
argument(d, "external") == true || argument(d, "usedOverridden") == true
|
|
1082
|
+
end
|
|
1083
|
+
|
|
1084
|
+
[field.name, Field.new(
|
|
1085
|
+
resolvable.filter_map { |d| subgraph(d) },
|
|
1086
|
+
external.filter_map { |d| subgraph(d) },
|
|
1087
|
+
resolvable.filter_map { |d| argument(d, "requires") }.first,
|
|
1088
|
+
resolvable.filter_map { |d| argument(d, "provides") }.first,
|
|
1089
|
+
applied.filter_map { |d| argument(d, "override") }.first,
|
|
1090
|
+
)]
|
|
1091
|
+
end.to_h
|
|
1092
|
+
end
|
|
1093
|
+
|
|
1094
|
+
def note_unknown(node, where)
|
|
1095
|
+
return unless node.respond_to?(:directives) && node.directives
|
|
1096
|
+
|
|
1097
|
+
node.directives.each do |directive|
|
|
1098
|
+
next unless directive.name.start_with?("join__")
|
|
1099
|
+
next if KNOWN.include?(directive.name)
|
|
1100
|
+
|
|
1101
|
+
@unsupported << "#{where} applies @#{directive.name}, which this table doesn't read"
|
|
1102
|
+
end
|
|
1103
|
+
end
|
|
1104
|
+
|
|
1105
|
+
# the subgraph NAME a directive's graph: argument points at
|
|
1106
|
+
def subgraph(directive) = @names[argument(directive, "graph")]
|
|
1107
|
+
|
|
1108
|
+
def argument(directive, name)
|
|
1109
|
+
return unless directive
|
|
1110
|
+
|
|
1111
|
+
value = directive.arguments.find { |arg| arg.name == name }&.value
|
|
1112
|
+
value.is_a?(GraphQL::Language::Nodes::Enum) ? value.name : value
|
|
1113
|
+
end
|
|
1114
|
+
end
|
|
352
1115
|
end
|