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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1357 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -136
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -251
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -98
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +617 -264
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +12 -6
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +21 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +15 -1
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +820 -57
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +59 -7
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +186 -62
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +194 -28
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +31 -6
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +74 -18
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +398 -170
  62. 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 GraphQL::Schema.from_introspection(source) if source.is_a?(Hash)
20
+ return build_introspection(source) if source.is_a?(Hash)
21
21
 
22
- if source.lstrip.start_with?("{") # introspection JSON content
23
- GraphQL::Schema.from_introspection(JSON.parse(source))
24
- elsif source.include?("\n") # multi-line: SDL content
25
- unless source.match?(/^\s*(schema|type|interface|union|enum|scalar|directive|input|")/)
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
- else # a file path
31
- case File.extname(source)
32
- when ".json"
33
- GraphQL::Schema.from_introspection(JSON.parse(File.read(source)))
34
- when ".graphql", ".gql"
35
- build_sdl(File.read(source))
36
- else
37
- raise ArgumentError, "unsupported schema format: #{source}"
38
- end
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 stripping Apollo Federation composition
43
- # machinery when the SDL is a composed supergraph so a supergraph dump
44
- # (often the only artifact for the merged graph, and what the router
45
- # actually serves) loads like any schema, with the federation plumbing
46
- # gone rather than leaked into schema.types. Plain SDL passes through.
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
- GraphQL::Schema.from_definition(federation_sdl?(sdl) ? strip_federation(sdl) : sdl)
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
- FEDERATION_PREFIXES = %w[join__ link__ core__].freeze
59
- FEDERATION_DIRECTIVES = %w[link core inaccessible].to_set.freeze
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
- # Whether a type/directive name belongs to the federation composition layer.
62
- def self.federation_name?(name)
63
- !!name && (name.start_with?(*FEDERATION_PREFIXES) || FEDERATION_DIRECTIVES.include?(name))
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 :federation_name?
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
- defs = remove_inaccessible(doc.definitions)
77
- .reject { |defn| defn.respond_to?(:name) && federation_name?(defn.name) }
78
- .map { |defn| strip_federation_directives(defn) }
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 (newly - removed.to_a).empty?
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
- def self.inaccessible?(node)
114
- node.respond_to?(:directives) && node.directives.any? { |d| d.name == "inaccessible" }
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
- changes[:directives] = node.directives.reject { |d| federation_name?(d.name) }
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); both load back identically
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.respond_to?(:url) ? transport.url : transport.class}") do
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 local/unannotated dumps.
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:verify` / `:refresh`.
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
- # a transport to the dump's recorded url (GRAPHWEAVER_AUTH supplies a
284
- # token when set)
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, "#{path} records no source url — pass transport:"
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["GRAPHWEAVER_AUTH"]).transport
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