graph_weaver 0.4.6 → 0.5.1

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1442 -0
  3. data/Gemfile.lock +23 -23
  4. data/README.md +115 -96
  5. data/docs/cassettes.md +93 -46
  6. data/docs/editors.md +82 -0
  7. data/docs/errors.md +34 -30
  8. data/docs/federation.md +521 -48
  9. data/docs/generated_modules.md +352 -137
  10. data/docs/getting_started.md +237 -67
  11. data/docs/logging.md +35 -6
  12. data/docs/real_world.md +21 -15
  13. data/docs/scalars.md +49 -154
  14. data/docs/testing.md +300 -52
  15. data/docs/transports.md +129 -30
  16. data/docs/upgrading.md +134 -0
  17. data/graph_weaver.gemspec +19 -3
  18. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  19. data/lib/graph_weaver/client.rb +118 -111
  20. data/lib/graph_weaver/codegen/aliases.rb +223 -0
  21. data/lib/graph_weaver/codegen/emit.rb +283 -261
  22. data/lib/graph_weaver/codegen/enum_type.rb +25 -124
  23. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  24. data/lib/graph_weaver/codegen/scalar_type.rb +69 -66
  25. data/lib/graph_weaver/codegen/type_helpers.rb +140 -0
  26. data/lib/graph_weaver/codegen.rb +672 -336
  27. data/lib/graph_weaver/errors.rb +154 -16
  28. data/lib/graph_weaver/federation.rb +259 -0
  29. data/lib/graph_weaver/hints.rb +9 -1
  30. data/lib/graph_weaver/in_process.rb +90 -0
  31. data/lib/graph_weaver/input_struct.rb +14 -2
  32. data/lib/graph_weaver/logging.rb +29 -0
  33. data/lib/graph_weaver/parsing.rb +59 -0
  34. data/lib/graph_weaver/query_module.rb +55 -0
  35. data/lib/graph_weaver/railtie.rb +23 -1
  36. data/lib/graph_weaver/representation.rb +74 -0
  37. data/lib/graph_weaver/response.rb +7 -0
  38. data/lib/graph_weaver/retry.rb +29 -8
  39. data/lib/graph_weaver/rspec.rb +220 -16
  40. data/lib/graph_weaver/schema_loader.rb +819 -60
  41. data/lib/graph_weaver/schemas.rb +48 -0
  42. data/lib/graph_weaver/selection.rb +43 -8
  43. data/lib/graph_weaver/tasks.rb +220 -22
  44. data/lib/graph_weaver/testing/cassette.rb +249 -81
  45. data/lib/graph_weaver/testing/coverage.rb +160 -0
  46. data/lib/graph_weaver/testing/failure.rb +14 -25
  47. data/lib/graph_weaver/testing/fake_client.rb +182 -22
  48. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  49. data/lib/graph_weaver/testing/router.rb +1452 -0
  50. data/lib/graph_weaver/testing/subgraphs.rb +134 -0
  51. data/lib/graph_weaver/testing.rb +209 -13
  52. data/lib/graph_weaver/transport/faraday.rb +28 -10
  53. data/lib/graph_weaver/transport/http.rb +99 -36
  54. data/lib/graph_weaver/transport.rb +67 -14
  55. data/lib/graph_weaver/version.rb +1 -1
  56. data/lib/graph_weaver.rb +416 -172
  57. metadata +25 -9
  58. data/CLAUDE.md +0 -69
  59. data/Makefile +0 -23
  60. data/NOTES.md +0 -182
  61. data/PLAN.md +0 -144
@@ -17,57 +17,390 @@ 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
+
48
+ def self.load_path(path)
49
+ case File.extname(path)
50
+ when ".json"
51
+ build_introspection(JSON.parse(read_schema(path)))
52
+ when ".graphql", ".gql"
53
+ build_sdl(read_schema(path))
54
+ else
55
+ raise GraphWeaver::Error,
56
+ "unsupported schema format: #{path} — expected a .json (introspection) or " \
57
+ ".graphql/.gql (SDL) path, or the content itself#{url_hint(path)}"
39
58
  end
40
59
  end
60
+ private_class_method :load_path
61
+
62
+ def self.read_schema(path)
63
+ File.read(path)
64
+ rescue SystemCallError => e
65
+ raise GraphWeaver::Error, "can't read the schema at #{path}: #{e.message}"
66
+ end
67
+ private_class_method :read_schema
68
+
69
+ # A bare host is the near miss worth naming: "unsupported schema format"
70
+ # sends you looking at the filesystem when the cause is the missing scheme.
71
+ HOST_LIKE = %r{\A[a-z0-9-]+(?:\.[a-z0-9-]+)+(?::\d+)?(?:/\S*)?\z}i
72
+ # dotted-but-not-a-host: a file whose extension we simply don't read
73
+ FILE_SUFFIXES = %w[yaml yml txt xml sdl md rb erb].freeze
74
+
75
+ def self.url_hint(source)
76
+ return unless source.match?(HOST_LIKE)
77
+
78
+ suffix = source[%r{\A[^/:]+}].to_s[/[^.]+\z/].to_s
79
+ return if FILE_SUFFIXES.include?(suffix.downcase)
80
+
81
+ %( — "#{source}" looks like a host; did you mean "https://#{source}"?)
82
+ end
83
+ private_class_method :url_hint
41
84
 
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.
85
+ # Build a schema from SDL, first normalizing whichever federation artifact
86
+ # it is: a composed supergraph gets its composition machinery stripped (so
87
+ # a supergraph dump — often the only artifact for the merged graph, and
88
+ # what the router actually serves loads like any schema, with the
89
+ # plumbing gone rather than leaked into schema.types); a subgraph gets the
90
+ # federation directive definitions it applies but doesn't declare. Plain
91
+ # SDL passes through.
47
92
  def self.build_sdl(sdl)
48
- GraphQL::Schema.from_definition(federation_sdl?(sdl) ? strip_federation(sdl) : sdl)
93
+ kind = sdl_kind(sdl)
94
+ prepared = case kind
95
+ when :supergraph then strip_federation(sdl)
96
+ when :subgraph then add_subgraph_definitions(sdl)
97
+ else sdl
98
+ end
99
+
100
+ build(kind) { GraphQL::Schema.from_definition(prepared) }
49
101
  end
50
102
  private_class_method :build_sdl
51
103
 
104
+ def self.build_introspection(result)
105
+ build(:introspection) { GraphQL::Schema.from_introspection(result) }
106
+ end
107
+ private_class_method :build_introspection
108
+
109
+ # Which artifact an SDL string is — it decides both the normalizing it
110
+ # needs and what to say when it won't build.
111
+ def self.sdl_kind(sdl)
112
+ if federation_sdl?(sdl)
113
+ :supergraph
114
+ elsif subgraph_sdl?(sdl)
115
+ :subgraph
116
+ else
117
+ :sdl
118
+ end
119
+ end
120
+ private_class_method :sdl_kind
121
+
122
+ SOURCE_KINDS = {
123
+ supergraph: "a federation supergraph SDL — if it's hand-written, check that nothing " \
124
+ "left in it still references an @inaccessible element",
125
+ subgraph: "a federation subgraph SDL — a directive it applies may be outside the " \
126
+ "subgraph spec; declare that one in the file",
127
+ sdl: "plain SDL — a supergraph is recognized by its @join__* markers or the @link/@core " \
128
+ "specs it declares, a subgraph by applied-but-undeclared @key/@shareable/…",
129
+ introspection: 'an introspection result — it should be the whole envelope, {"data": {"__schema": …}}',
130
+ }.freeze
131
+
132
+ # graphql-ruby reports a schema it can't build with whatever its internals
133
+ # happen to raise — NoMethodError, ParseError, a bare RuntimeError — often
134
+ # naming a document the caller never wrote (we reprint federation SDL before
135
+ # building). Keep those under the umbrella, and say which artifact we took
136
+ # the source for.
137
+ def self.build(kind)
138
+ yield
139
+ rescue GraphWeaver::Error
140
+ raise
141
+ rescue StandardError => e
142
+ raise GraphWeaver::Error,
143
+ "couldn't build a schema from #{SOURCE_KINDS.fetch(kind)} (#{e.class}: #{e.message})"
144
+ end
145
+ private_class_method :build
146
+
147
+ # A composed graph declares the specs that compose it: fed-2 links join,
148
+ # fed-1 @cores the core spec itself. Neither matches a subgraph, which links
149
+ # only the federation spec — so this catches the composed graphs the
150
+ # @join__ marker misses: one that renamed join (`as: "j"`), and a core
151
+ # schema that merged nothing but still carries core__Purpose.
152
+ COMPOSITION_SPEC = %r{@(?:link\s*\(\s*url|core\s*\(\s*feature):\s*"https://specs\.apollo\.dev/(?:join|core)/}
153
+
52
154
  # A composed Fed2 supergraph is marked by @join__* directives (every merged
53
155
  # type carries them); a plain schema has none.
54
156
  def self.federation_sdl?(sdl)
55
- sdl.match?(/@join__\w/)
157
+ sdl.match?(/@join__\w/) || sdl.match?(COMPOSITION_SPEC)
56
158
  end
57
159
 
58
- FEDERATION_PREFIXES = %w[join__ link__ core__].freeze
59
- FEDERATION_DIRECTIVES = %w[link core inaccessible].to_set.freeze
160
+ # The federation spec a fed-2 subgraph @links, and the directives a fed-1
161
+ # subgraph just applies. Both leave the definitions off the file: fed-1
162
+ # treats them as implicit, fed-2 imports them. A composed supergraph
163
+ # defines everything it applies (and federation_sdl? catches it first).
164
+ SUBGRAPH_LINK = %r{@link\s*\(\s*url:\s*"https://specs\.apollo\.dev/federation/}
165
+ SUBGRAPH_MARKERS = %w[key external extends provides requires shareable override interfaceObject].freeze
166
+
167
+ # A raw subgraph SDL — `rover subgraph fetch`, `_service { sdl }`, or the
168
+ # .graphql in a service repo — rather than a composed graph.
169
+ def self.subgraph_sdl?(sdl)
170
+ return false if federation_sdl?(sdl)
171
+ return true if sdl.match?(SUBGRAPH_LINK)
172
+
173
+ SUBGRAPH_MARKERS.any? { |name| sdl.match?(/@#{name}\b/) && !sdl.match?(/\bdirective\s+@#{name}\b/) }
174
+ end
175
+
176
+ # The subgraph spec's directives and the types they reference, keyed by
177
+ # what a definition in the SDL would be named ("@key" for a directive).
178
+ # The helper scalars are spelled `federation__*` — they are weaver's own
179
+ # injections into someone else's schema, and a subgraph with its own
180
+ # `FieldSet` type must not collide with one.
181
+ # https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/reference/subgraph-spec
182
+ SUBGRAPH_DIRECTIVE_DEFS = {
183
+ "@key" => "directive @key(fields: federation__FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE",
184
+ "@external" => "directive @external on OBJECT | FIELD_DEFINITION",
185
+ "@requires" => "directive @requires(fields: federation__FieldSet!) on FIELD_DEFINITION",
186
+ "@provides" => "directive @provides(fields: federation__FieldSet!) on FIELD_DEFINITION",
187
+ "@shareable" => "directive @shareable repeatable on OBJECT | FIELD_DEFINITION",
188
+ "@extends" => "directive @extends on OBJECT | INTERFACE",
189
+ "@override" => "directive @override(from: String!, label: String) on FIELD_DEFINITION",
190
+ "@interfaceObject" => "directive @interfaceObject on OBJECT",
191
+ "@inaccessible" => "directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION",
192
+ "@tag" => "directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA",
193
+ "@composeDirective" => "directive @composeDirective(name: String!) repeatable on SCHEMA",
194
+ "@authenticated" => "directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM",
195
+ "@requiresScopes" => "directive @requiresScopes(scopes: [[federation__Scope!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM",
196
+ "@policy" => "directive @policy(policies: [[federation__Policy!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM",
197
+ "@link" => "directive @link(url: String!, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA",
198
+ }.freeze
199
+
200
+ # The types those definitions reference — injected only alongside a
201
+ # directive that needs one. `_Any`/`_Entity`/`_Service` (see
202
+ # entity_plumbing) keep their spec-mandated names; these are ours to
203
+ # namespace, so they can't shadow a subgraph's own type.
204
+ SUBGRAPH_HELPER_TYPES = {
205
+ "federation__FieldSet" => "scalar federation__FieldSet",
206
+ "federation__Scope" => "scalar federation__Scope",
207
+ "federation__Policy" => "scalar federation__Policy",
208
+ "link__Import" => "scalar link__Import",
209
+ "link__Purpose" => "enum link__Purpose { SECURITY EXECUTION }",
210
+ }.freeze
211
+
212
+ # A fed-2 subgraph that @links the spec under a namespace — `as: "fed"`,
213
+ # and "federation" is the default — applies every non-imported directive
214
+ # under it: @federation__key rather than @key.
215
+ def self.subgraph_namespace(sdl)
216
+ sdl[/#{SUBGRAPH_LINK}[^)]*\bas:\s*"([^"]+)"/, 1] || "federation"
217
+ end
218
+ private_class_method :subgraph_namespace
219
+
220
+ # Prepend the definitions this subgraph applies but doesn't declare, under
221
+ # whichever name it applies them by. Only the missing ones — a duplicate
222
+ # definition is a hard error in graphql-ruby, and a subgraph spelling out
223
+ # its own @key (fed-1 style, or a differing shape) must win.
224
+ def self.add_subgraph_definitions(sdl)
225
+ defined = GraphQL.parse(sdl).definitions.filter_map do |defn|
226
+ next unless defn.respond_to?(:name)
227
+
228
+ defn.is_a?(GraphQL::Language::Nodes::DirectiveDefinition) ? "@#{defn.name}" : defn.name
229
+ end.to_set
230
+ namespace = subgraph_namespace(sdl)
231
+
232
+ directives = SUBGRAPH_DIRECTIVE_DEFS.filter_map do |name, defn|
233
+ applied = [name, "@#{namespace}__#{name.delete_prefix("@")}"]
234
+ .find { |as| !defined.include?(as) && sdl.match?(/#{as}\b/) }
235
+ applied && defn.sub(name, applied)
236
+ end
237
+ types = SUBGRAPH_HELPER_TYPES
238
+ .select { |name, _| !defined.include?(name) && directives.any? { |defn| defn.include?(name) } }
239
+ .values
240
+
241
+ added = types + directives + entity_plumbing(sdl, namespace, defined)
242
+ added.empty? ? sdl : "#{added.join("\n")}\n\n#{sdl}"
243
+ end
244
+ private_class_method :add_subgraph_definitions
245
+
246
+ # The entity resolver every subgraph serves — and which no subgraph SDL
247
+ # contains: `_service { sdl }` and `rover subgraph fetch` both print the
248
+ # published schema, where the plumbing is implicit. Supply it so an
249
+ # `_entities` query can be typed against the artifact you actually have
250
+ # (a supergraph doesn't describe `_entities` at all). `_Entity` is the
251
+ # union of the file's own @key'd types, so it stays accurate per subgraph.
252
+ # https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/reference/subgraph-spec
253
+ def self.entity_plumbing(sdl, namespace, defined)
254
+ doc = GraphQL.parse(sdl)
255
+ root = query_root_name(doc)
256
+ entities = entity_names(doc, namespace)
257
+ return [] if entities.empty? || root.nil? || defined.include?("_Any")
258
+
259
+ [
260
+ "scalar _Any",
261
+ "type _Service { sdl: String }",
262
+ "union _Entity = #{entities.join(" | ")}",
263
+ "extend type #{root} {\n" \
264
+ " _entities(representations: [_Any!]!): [_Entity]!\n" \
265
+ " _service: _Service!\n" \
266
+ "}",
267
+ ]
268
+ end
269
+ private_class_method :entity_plumbing
270
+
271
+ # The object types this subgraph resolves as entities: the ones it applies
272
+ # @key to, under whichever name it applies it by (@federation__key when the
273
+ # spec is linked under a namespace). Type extensions count — fed-1 spells an
274
+ # entity it doesn't own as `extend type User @key(...)`.
275
+ def self.entity_names(doc, namespace)
276
+ key_names = ["key", "#{namespace}__key"]
277
+
278
+ doc.definitions.filter_map do |defn|
279
+ next unless defn.is_a?(GraphQL::Language::Nodes::ObjectTypeDefinition) ||
280
+ defn.is_a?(GraphQL::Language::Nodes::ObjectTypeExtension)
281
+
282
+ defn.name if defn.directives.any? { |d| key_names.include?(d.name) }
283
+ end.uniq
284
+ end
285
+ private_class_method :entity_names
286
+
287
+ # The query root's name — `schema { query: Root }` when the file says so,
288
+ # `Query` by convention. nil when the file has no query root to extend.
289
+ def self.query_root_name(doc)
290
+ declared = doc.definitions.grep(GraphQL::Language::Nodes::SchemaDefinition).first ||
291
+ doc.definitions.grep(GraphQL::Language::Nodes::SchemaExtension).first
292
+ name = declared&.query || "Query"
293
+ root_present = doc.definitions.any? do |defn|
294
+ defn.respond_to?(:name) && defn.name == name &&
295
+ (defn.is_a?(GraphQL::Language::Nodes::ObjectTypeDefinition) ||
296
+ defn.is_a?(GraphQL::Language::Nodes::ObjectTypeExtension))
297
+ end
298
+ name if root_present
299
+ end
300
+ private_class_method :query_root_name
301
+
302
+ # how a schema declares the specs it's built from: @link in fed 2, @core in fed 1
303
+ LINK_DIRECTIVES = %w[link core].to_set.freeze
304
+
305
+ # The floor a supergraph gets whether it declares the specs or not — a
306
+ # hand-written or trimmed one often has no @link header at all. Derivation
307
+ # only ever adds to this.
308
+ DEFAULT_PREFIXES = %w[join__ link__ core__].freeze
309
+ DEFAULT_DIRECTIVES = %w[link core inaccessible].freeze
310
+
311
+ # What THIS document calls federation's machinery — type-name prefixes,
312
+ # directive names, and the local names @inaccessible answers to — read off
313
+ # its own @link/@core declarations rather than assumed: a linked spec's name
314
+ # gives both a `join__` type prefix and a root `@join` directive, `as:`
315
+ # renames it, and each `import:` entry binds one more directive in the root
316
+ # namespace, possibly under a different local name. A missed @inaccessible
317
+ # rename over-permits the derived API schema, not merely leaks a type.
318
+ # https://specs.apollo.dev/link/v1.0/ · https://specs.apollo.dev/core/v0.2/
319
+ def self.link_namespaces(doc)
320
+ prefixes = DEFAULT_PREFIXES.dup
321
+ directives = DEFAULT_DIRECTIVES.to_set
322
+ inaccessible = Set["inaccessible"]
323
+
324
+ link_declarations(doc).each do |args|
325
+ spec = spec_name(args["url"] || args["feature"])
326
+ local = args["as"].is_a?(String) ? args["as"] : spec
327
+ if local
328
+ prefixes << "#{local}__"
329
+ directives << local
330
+ # a spec's root directive is its own name: @link(url: ".../inaccessible/v0.2", as: "private")
331
+ inaccessible << local if spec == "inaccessible"
332
+ end
333
+
334
+ imports(args["import"]).each do |name, as|
335
+ next unless as.start_with?("@")
336
+
337
+ directives << as.delete_prefix("@")
338
+ inaccessible << as.delete_prefix("@") if name == "@inaccessible"
339
+ end
340
+ end
341
+
342
+ { prefixes: prefixes.uniq.freeze, directives: directives.freeze, inaccessible: inaccessible.freeze }
343
+ end
344
+ private_class_method :link_namespaces
345
+
346
+ # The @link/@core applications on the document's schema definition, each as
347
+ # a plain argument hash.
348
+ def self.link_declarations(doc)
349
+ doc.definitions.flat_map do |defn|
350
+ next [] unless defn.is_a?(GraphQL::Language::Nodes::SchemaDefinition) ||
351
+ defn.is_a?(GraphQL::Language::Nodes::SchemaExtension)
352
+
353
+ defn.directives
354
+ .select { |d| LINK_DIRECTIVES.include?(d.name) }
355
+ .map { |d| d.arguments.to_h { |arg| [arg.name, arg.value] } }
356
+ end
357
+ end
358
+ private_class_method :link_declarations
359
+
360
+ # `import: ["@key", {name: "@inaccessible", as: "@private"}]` as
361
+ # [spec name, local name] pairs.
362
+ def self.imports(value)
363
+ Array(value).filter_map do |entry|
364
+ case entry
365
+ when String then [entry, entry]
366
+ when GraphQL::Language::Nodes::InputObject
367
+ fields = entry.to_h
368
+ name = fields["name"]
369
+ [name, fields["as"].is_a?(String) ? fields["as"] : name] if name.is_a?(String)
370
+ end
371
+ end
372
+ end
373
+ private_class_method :imports
374
+
375
+ VERSION_TAG = /\Av\d+\.\d+\z/
376
+ GRAPHQL_NAME = /\A[A-Za-z][A-Za-z0-9_]*\z/
377
+
378
+ # The name a linked spec's elements are namespaced under: the URL's
379
+ # penultimate path segment when the last is a version tag, else the last one.
380
+ # Query strings, fragments and empty segments don't count. A segment that
381
+ # can't be a namespace (a bare host, or a name carrying the `__` separator)
382
+ # means the URL is just an opaque identifier and nothing is derived.
383
+ # https://specs.apollo.dev/link/v1.0/
384
+ def self.spec_name(url)
385
+ return unless url.is_a?(String)
386
+
387
+ segments = url.split(/[?#]/).first.to_s.split("/").reject(&:empty?)
388
+ segments.pop if segments.last&.match?(VERSION_TAG)
389
+ name = segments.last
390
+ name if name&.match?(GRAPHQL_NAME) && !name.include?("__") && !name.end_with?("_")
391
+ end
392
+ private_class_method :spec_name
60
393
 
61
394
  # Synthetic composition TYPES are always prefixed (join__Graph, link__Import).
62
395
  # The bare names (link/core/inaccessible) are DIRECTIVES only — a user type
63
396
  # literally named `link` (Hasura-style lowercase) must not be dropped.
64
- def self.federation_type_name?(name)
65
- !!name && name.start_with?(*FEDERATION_PREFIXES)
397
+ def self.federation_type_name?(name, ns)
398
+ !!name && name.start_with?(*ns[:prefixes])
66
399
  end
67
400
  private_class_method :federation_type_name?
68
401
 
69
- def self.federation_directive_name?(name)
70
- !!name && (name.start_with?(*FEDERATION_PREFIXES) || FEDERATION_DIRECTIVES.include?(name))
402
+ def self.federation_directive_name?(name, ns)
403
+ !!name && (name.start_with?(*ns[:prefixes]) || ns[:directives].include?(name))
71
404
  end
72
405
  private_class_method :federation_directive_name?
73
406
 
@@ -80,9 +413,10 @@ module GraphWeaver::SchemaLoader
80
413
  # and no join__* leaking into schema.types.
81
414
  def self.strip_federation(sdl)
82
415
  doc = GraphQL.parse(sdl)
83
- defs = remove_inaccessible(doc.definitions)
84
- .reject { |defn| federation_definition?(defn) }
85
- .map { |defn| strip_federation_directives(defn) }
416
+ ns = link_namespaces(doc)
417
+ defs = remove_inaccessible(doc.definitions, ns)
418
+ .reject { |defn| federation_definition?(defn, ns) }
419
+ .map { |defn| strip_federation_directives(defn, ns) }
86
420
 
87
421
  if defs.none? { |d| d.is_a?(GraphQL::Language::Nodes::ObjectTypeDefinition) }
88
422
  raise GraphWeaver::Error,
@@ -95,13 +429,13 @@ module GraphWeaver::SchemaLoader
95
429
 
96
430
  # a synthetic composition definition to drop: a federation directive
97
431
  # definition (by name), or a synthetic join__*/link__* type (by prefix)
98
- def self.federation_definition?(defn)
432
+ def self.federation_definition?(defn, ns)
99
433
  return false unless defn.respond_to?(:name)
100
434
 
101
435
  if defn.is_a?(GraphQL::Language::Nodes::DirectiveDefinition)
102
- federation_directive_name?(defn.name)
436
+ federation_directive_name?(defn.name, ns)
103
437
  else
104
- federation_type_name?(defn.name)
438
+ federation_type_name?(defn.name, ns)
105
439
  end
106
440
  end
107
441
  private_class_method :federation_definition?
@@ -114,12 +448,12 @@ module GraphWeaver::SchemaLoader
114
448
  # removed — repeated to a fixpoint. So codegen matches exactly what clients
115
449
  # can query, without the over-permitting a raw supergraph would allow and
116
450
  # without Apollo's JS tooling to subtract the API schema.
117
- def self.remove_inaccessible(definitions)
118
- removed = definitions.select { |d| type_definition?(d) && inaccessible?(d) }.map(&:name).to_set
451
+ def self.remove_inaccessible(definitions, ns)
452
+ removed = definitions.select { |d| type_definition?(d) && inaccessible?(d, ns) }.map(&:name).to_set
119
453
  loop do
120
454
  survivors = definitions
121
455
  .reject { |d| type_definition?(d) && removed.include?(d.name) }
122
- .map { |d| prune_inaccessible(d, removed) }
456
+ .map { |d| prune_inaccessible(d, removed, ns) }
123
457
  # survivors already exclude `removed`, so anything newly emptied is fresh
124
458
  newly = survivors.select { |d| type_definition?(d) && type_emptied?(d) }.map(&:name)
125
459
  return survivors if newly.empty?
@@ -138,8 +472,9 @@ module GraphWeaver::SchemaLoader
138
472
  end
139
473
  private_class_method :type_definition?
140
474
 
141
- def self.inaccessible?(node)
142
- node.respond_to?(:directives) && node.directives.any? { |d| d.name == "inaccessible" }
475
+ # @inaccessible under whatever local name the schema's @link bound it to.
476
+ def self.inaccessible?(node, ns)
477
+ node.respond_to?(:directives) && node.directives.any? { |d| ns[:inaccessible].include?(d.name) }
143
478
  end
144
479
  private_class_method :inaccessible?
145
480
 
@@ -164,9 +499,9 @@ module GraphWeaver::SchemaLoader
164
499
  # Remove @inaccessible children and children referencing a removed type,
165
500
  # from a type's fields (and their arguments), enum values, union members,
166
501
  # and implemented interfaces.
167
- def self.prune_inaccessible(node, removed)
502
+ def self.prune_inaccessible(node, removed, ns)
168
503
  gone = lambda do |child|
169
- inaccessible?(child) || (child.respond_to?(:type) && removed.include?(unwrapped_type_name(child)))
504
+ inaccessible?(child, ns) || (child.respond_to?(:type) && removed.include?(unwrapped_type_name(child)))
170
505
  end
171
506
 
172
507
  changes = {}
@@ -176,6 +511,10 @@ module GraphWeaver::SchemaLoader
176
511
  args && args.any? ? field.merge(arguments: args.reject(&gone)) : field
177
512
  end
178
513
  end
514
+ # a directive definition's own arguments — the only top-level node with
515
+ # them, and Apollo's REFERENCED_INACCESSIBLE rule should already have
516
+ # rejected such a supergraph; belt and braces for hand-written ones
517
+ changes[:arguments] = node.arguments.reject(&gone) if node.respond_to?(:arguments) && node.arguments
179
518
  changes[:values] = node.values.reject(&gone) if node.respond_to?(:values) && node.values
180
519
  changes[:types] = node.types.reject { |t| removed.include?(t.name) } if node.respond_to?(:types) && node.types
181
520
  if node.respond_to?(:interfaces) && node.interfaces
@@ -187,14 +526,23 @@ module GraphWeaver::SchemaLoader
187
526
 
188
527
  # Recursively remove @join__*/@link applications from a definition and its
189
528
  # fields, arguments, and enum values.
190
- def self.strip_federation_directives(node)
529
+ def self.strip_federation_directives(node, ns)
191
530
  changes = {}
192
531
  if node.respond_to?(:directives) && node.directives
193
- changes[:directives] = node.directives.reject { |d| federation_directive_name?(d.name) }
532
+ # A schema definition keeps NONE: graphql-ruby's printer omits the
533
+ # `{ query: Query }` body when the root type names are conventional but
534
+ # still prints directives, so any survivor (@tag, @composeDirective, a
535
+ # composed custom one) reprints as an unparseable braceless `schema @tag`.
536
+ # Codegen never reads schema directives.
537
+ changes[:directives] = if node.is_a?(GraphQL::Language::Nodes::SchemaDefinition)
538
+ []
539
+ else
540
+ node.directives.reject { |d| federation_directive_name?(d.name, ns) }
541
+ end
194
542
  end
195
- changes[:fields] = node.fields.map { |c| strip_federation_directives(c) } if node.respond_to?(:fields) && node.fields
196
- changes[:arguments] = node.arguments.map { |c| strip_federation_directives(c) } if node.respond_to?(:arguments) && node.arguments
197
- changes[:values] = node.values.map { |c| strip_federation_directives(c) } if node.respond_to?(:values) && node.values
543
+ changes[:fields] = node.fields.map { |c| strip_federation_directives(c, ns) } if node.respond_to?(:fields) && node.fields
544
+ changes[:arguments] = node.arguments.map { |c| strip_federation_directives(c, ns) } if node.respond_to?(:arguments) && node.arguments
545
+ changes[:values] = node.values.map { |c| strip_federation_directives(c, ns) } if node.respond_to?(:values) && node.values
198
546
  changes.empty? ? node : node.merge(changes)
199
547
  end
200
548
  private_class_method :strip_federation_directives
@@ -212,7 +560,10 @@ module GraphWeaver::SchemaLoader
212
560
  # reads (its extension picks the format)
213
561
  # - a path — the extension picks the format: .json is the verbatim
214
562
  # introspection result, .graphql/.gql is SDL (human-readable,
215
- # PR-reviewable diffs); both load back identically
563
+ # PR-reviewable diffs). Both load back to the same schema, but not
564
+ # at the same cost: SDL has to be parsed, so on a big schema
565
+ # (GitHub's 2.9 MB) it's roughly twice as slow to load — SDL for
566
+ # reviewability, JSON when boot time matters.
216
567
  # - :json / :graphql / :gql — GraphWeaver.schema_path's location, in
217
568
  # that format
218
569
  # Reading is format-agnostic: any fresh sibling dump counts, whatever
@@ -230,7 +581,7 @@ module GraphWeaver::SchemaLoader
230
581
  # GraphWeaver::SchemaLoader.introspect(transport).to_json
231
582
  # end
232
583
  # schema = GraphWeaver::SchemaLoader.load(json)
233
- def self.introspect(transport, cache: nil, ttl: nil)
584
+ def self.introspect(transport, cache: nil, ttl: nil, auth_env: nil)
234
585
  cache = cache_path(cache)
235
586
 
236
587
  if cache
@@ -246,12 +597,21 @@ module GraphWeaver::SchemaLoader
246
597
  GraphWeaver.log(:info) { "schema cache miss: #{cache}" }
247
598
  end
248
599
 
249
- result = GraphWeaver.log_timed(:info, "introspected #{transport.respond_to?(:url) ? transport.url : transport.class}") do
600
+ result = GraphWeaver.log_timed(:info, "introspected #{endpoint(transport)}") do
250
601
  transport.execute(GraphQL::Introspection.query, variables: {}).to_h
251
602
  end
252
603
  if (errors = result["errors"])
253
604
  raise GraphWeaver::Error, "introspection failed: #{errors.inspect}"
254
605
  end
606
+ # a 200 of well-formed JSON that isn't an introspection result — a REST
607
+ # base url, a GraphiQL page, a proxy that ate the path. from_introspection
608
+ # would raise a bare NoMethodError on the missing "__schema" key.
609
+ data = result["data"]
610
+ unless data.is_a?(Hash) && data["__schema"]
611
+ raise GraphWeaver::Error,
612
+ "introspection at #{endpoint(transport)} returned no __schema — is that a GraphQL " \
613
+ "endpoint? got: #{result.inspect[0, 200]}"
614
+ end
255
615
 
256
616
  schema = GraphQL::Schema.from_introspection(result)
257
617
 
@@ -260,7 +620,7 @@ module GraphWeaver::SchemaLoader
260
620
  # the extension picks the format: .json is the verbatim wire
261
621
  # artifact; .graphql/.gql is SDL — human-readable, PR-reviewable
262
622
  # diffs (both generate byte-identical code)
263
- meta = stamp(transport)
623
+ meta = stamp(transport, auth_env)
264
624
  content = if cache.end_with?(".json")
265
625
  JSON.generate(meta ? result.merge("graph_weaver" => meta) : result)
266
626
  else
@@ -274,6 +634,13 @@ module GraphWeaver::SchemaLoader
274
634
  schema
275
635
  end
276
636
 
637
+ # What to call the thing we introspected, for a log line or an error: its
638
+ # url when it has one, else the class (a schema class, a fake).
639
+ def self.endpoint(transport)
640
+ (transport.respond_to?(:url) && transport.url) || transport.class
641
+ end
642
+ private_class_method :endpoint
643
+
277
644
  # The conventional schema dump, whatever its format: schema_path or the
278
645
  # first sibling extension that exists. nil when none is on disk.
279
646
  def self.locate_path(path = GraphWeaver.schema_path)
@@ -286,8 +653,13 @@ module GraphWeaver::SchemaLoader
286
653
  found && load(found)
287
654
  end
288
655
 
656
+ # The ENV var holding the token for a private API, when the dump doesn't
657
+ # name its own.
658
+ DEFAULT_AUTH_ENV = "GRAPHWEAVER_AUTH"
659
+
289
660
  # The provenance recorded in a dump ({"url" => ..., "introspected_at"
290
- # => ...}), whichever format holds it; nil for local/unannotated dumps.
661
+ # => ..., "auth_env" => ...}), whichever format holds it; nil for
662
+ # local/unannotated dumps.
291
663
  def self.provenance(path)
292
664
  content = File.read(path)
293
665
  if path.end_with?(".json")
@@ -297,10 +669,19 @@ module GraphWeaver::SchemaLoader
297
669
  end
298
670
  end
299
671
 
672
+ # The ENV var a dump's token lives in — whichever the generator recorded,
673
+ # else GRAPHWEAVER_AUTH. Keeps `--auth MY_TOKEN` from producing an app
674
+ # that authenticates and rake tasks that 401.
675
+ def self.auth_env(path = nil)
676
+ # the first refresh names a dump that doesn't exist yet
677
+ recorded = provenance(path)&.dig("auth_env") if path && File.exist?(path)
678
+ recorded || DEFAULT_AUTH_ENV
679
+ end
680
+
300
681
  # Re-introspect a dump's source and compare — true when the server has
301
682
  # drifted from what's on disk. transport: overrides the transport (auth
302
683
  # etc); by default one is built from the dump's recorded url. Wired up
303
- # as `rake graph_weaver:schema:verify` / `:refresh`.
684
+ # as `rake graph_weaver:schema:diff` / `:refresh`.
304
685
  def self.stale?(path, transport: nil)
305
686
  transport ||= source_transport(path)
306
687
  fresh = introspect(transport)
@@ -308,28 +689,63 @@ module GraphWeaver::SchemaLoader
308
689
  fresh.to_definition != load(path).to_definition
309
690
  end
310
691
 
311
- # a transport to the dump's recorded url (GRAPHWEAVER_AUTH supplies a
312
- # token when set)
692
+ # Re-introspect and rewrite the local dump, returning [path, url].
693
+ # url: defaults to the one the dump recorded, so a refresh needs no
694
+ # arguments once a dump exists — and passing one bootstraps the first
695
+ # dump, which is what `rails g graph_weaver:install` does.
696
+ # auth_env: the ENV var holding the token — defaults to whichever the
697
+ # dump recorded, so `--auth MY_TOKEN` keeps working on every later
698
+ # refresh without being repeated. auth: passes a token directly.
699
+ def self.refresh!(url: nil, auth_env: nil, auth: nil)
700
+ path = locate_path
701
+ url ||= path && provenance(path)&.dig("url")
702
+ raise GraphWeaver::Error, refresh_hint(path) unless url
703
+
704
+ path ||= GraphWeaver.schema_path
705
+ auth_env ||= self.auth_env(path)
706
+ auth ||= ENV[auth_env]
707
+ # ttl: 0 — an existing dump never counts as fresh, a refresh always refetches
708
+ introspect(GraphWeaver.new(url, auth:).transport, cache: path, ttl: 0, auth_env:)
709
+ [path, url]
710
+ end
711
+
712
+ def self.refresh_hint(path)
713
+ missing = path ? "#{path} records no source url" : "no schema dump at #{GraphWeaver.schema_path}"
714
+ "#{missing} — pass one: rake graph_weaver:schema:refresh URL=https://api.example.com/graphql " \
715
+ "(a dump taken from a schema class is rebuilt from code, not re-fetched — see " \
716
+ "docs/getting_started.md#your-apps-own-schema-in-process)"
717
+ end
718
+ private_class_method :refresh_hint
719
+
720
+ # A transport to the dump's recorded url, authenticated from whichever ENV
721
+ # var the dump named (else DEFAULT_AUTH_ENV). The single way to reach a
722
+ # dump's own server — building one at a call site is how `--auth MY_TOKEN`
723
+ # ends up honoured in some places and not others.
313
724
  def self.source_transport(path)
314
725
  meta = provenance(path)
315
726
  unless meta&.key?("url")
316
- raise GraphWeaver::Error, "#{path} records no source url — pass transport:"
727
+ raise GraphWeaver::Error,
728
+ "#{path} records no source url — it wasn't introspected from one. Pass transport:, " \
729
+ "or rebuild it from the schema class that produced it."
317
730
  end
318
731
 
319
- GraphWeaver.new(meta["url"], auth: ENV["GRAPHWEAVER_AUTH"]).transport
732
+ GraphWeaver.new(meta["url"], auth: ENV[auth_env(path)]).transport
320
733
  end
321
- private_class_method :source_transport
322
734
 
323
735
  # Where a dump came from, recorded into the file so it can be
324
736
  # re-verified later — a parsable header comment in SDL, a
325
737
  # "graph_weaver" sibling key in introspection JSON (from_introspection
326
738
  # reads only "data"). nil when the transport has no url (schema
327
739
  # classes, fakes).
328
- def self.stamp(transport)
740
+ def self.stamp(transport, auth_env = nil)
329
741
  return unless transport.respond_to?(:url) && transport.url
330
742
 
331
743
  require "time"
332
- { "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
333
749
  end
334
750
  private_class_method :stamp
335
751
 
@@ -377,4 +793,347 @@ module GraphWeaver::SchemaLoader
377
793
  File.exist?(path) && (ttl.nil? || Time.now - File.mtime(path) < ttl)
378
794
  end
379
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
+ # A `subgraphs:` map with string keys, refusing a name this supergraph
942
+ # doesn't have — a typo'd key would otherwise silently configure nothing.
943
+ # Lives here so the test router and the drift check refuse identically:
944
+ # two copies of this drifted apart once already.
945
+ def named_subgraphs(given)
946
+ map = (given || {}).to_h { |name, schema| [name.to_s, schema] }
947
+ unknown = map.keys - subgraphs
948
+ return map if unknown.empty?
949
+
950
+ raise GraphWeaver::ConfigurationError, "subgraphs: names #{unknown.join(", ")}, which " \
951
+ "this supergraph doesn't have (its subgraphs are #{subgraphs.join(", ")})"
952
+ end
953
+
954
+ # The @key field sets a subgraph will answer an `_entities` fetch on, each
955
+ # as a list of dotted paths ("id organization { id }" => ["id",
956
+ # "organization.id"]). A `resolvable: false` key declares a shape this
957
+ # subgraph does not answer for, so it isn't one.
958
+ def keys(type_name, subgraph) = @keys.dig(type_name, subgraph) || []
959
+
960
+ # whether any subgraph will resolve this type from a key
961
+ def entity?(type_name) = (@keys[type_name] || {}).each_value.any?(&:any?)
962
+
963
+ # The concrete types `subgraph` can answer an abstract type with — a
964
+ # union's members there, an interface's implementations there. Only these
965
+ # may be named in a fetch to it: a subgraph rejects an `... on T` its own
966
+ # schema doesn't place in the abstract type.
967
+ #
968
+ # nil when the supergraph doesn't say, which a caller has to treat as
969
+ # unknown rather than empty. @join__unionMember / @join__implements record
970
+ # it; a supergraph composed before those existed carries neither, and then
971
+ # only a type declared in ONE subgraph is answerable — everything it can
972
+ # possibly return is that subgraph's.
973
+ def possible_types(type_name, subgraph)
974
+ all = @abstract[type_name] or return
975
+
976
+ per_graph = @possible[type_name]
977
+ return per_graph[subgraph] || [] if per_graph
978
+
979
+ all if declared_in(type_name).one?
980
+ end
981
+
982
+ def inspect = "#<#{self.class.name} subgraphs=#{@subgraphs.inspect}>"
983
+ alias to_s inspect
984
+
985
+ # A @key/@requires/@provides field set is a selection set. Flattened to
986
+ # dotted paths, so a nested one is recognizable as nested by its shape.
987
+ def self.parse_field_set(text)
988
+ flatten(GraphQL.parse("{ #{text} }").definitions.first.selections, [])
989
+ end
990
+
991
+ def self.flatten(selections, prefix)
992
+ selections.flat_map do |node|
993
+ unless node.is_a?(GraphQL::Language::Nodes::Field)
994
+ raise GraphWeaver::Error, "a field set holds plain fields only, got #{node.class}"
995
+ end
996
+
997
+ if node.selections.any?
998
+ flatten(node.selections, prefix + [node.name])
999
+ else
1000
+ [(prefix + [node.name]).join(".")]
1001
+ end
1002
+ end
1003
+ end
1004
+ private_class_method :flatten
1005
+
1006
+ private
1007
+
1008
+ # join__Graph's enum values ARE the subgraphs: ACCOUNTS
1009
+ # @join__graph(name: "accounts", url: "...").
1010
+ #
1011
+ # This table reads the join spec under its default name, and only that —
1012
+ # a supergraph that renamed it (`@link(url: ".../join/v0.3", as: "j")`)
1013
+ # spells every marker `j__` and lands here with nothing found. Being told
1014
+ # a composed graph has no subgraphs is worse than being refused, so an
1015
+ # empty read is `unsupported` rather than an answer.
1016
+ def read_graphs
1017
+ enum = @document.definitions.find do |defn|
1018
+ defn.is_a?(GraphQL::Language::Nodes::EnumTypeDefinition) && defn.name == "join__Graph"
1019
+ end
1020
+
1021
+ enum&.values&.each do |value|
1022
+ name = argument(value.directives.find { |d| d.name == "join__graph" }, "name")
1023
+ @names[value.name] = name if name
1024
+ end
1025
+
1026
+ return if @names.any?
1027
+
1028
+ @unsupported << "no join__Graph enum names the subgraphs — this schema declares a " \
1029
+ "composition spec, so either it merged nothing, or it renamed the join spec " \
1030
+ "(@link(url: \".../join/v0.3\", as: \"...\")), which this table doesn't follow"
1031
+ end
1032
+
1033
+ # What each abstract type can be, from the SDL alone — a union's members,
1034
+ # and the objects that name an interface in their `implements`. The
1035
+ # per-subgraph split comes from @join__ directives in read_types; this is
1036
+ # the whole of it, which is all a single-subgraph abstract type needs.
1037
+ def read_abstracts
1038
+ @document.definitions.each do |defn|
1039
+ case defn
1040
+ when GraphQL::Language::Nodes::UnionTypeDefinition
1041
+ @abstract[defn.name] = defn.types.map(&:name)
1042
+ when GraphQL::Language::Nodes::InterfaceTypeDefinition
1043
+ @abstract[defn.name] ||= []
1044
+ when GraphQL::Language::Nodes::ObjectTypeDefinition
1045
+ defn.interfaces.each { |iface| (@abstract[iface.name] ||= []) << defn.name }
1046
+ end
1047
+ end
1048
+ end
1049
+
1050
+ # An interface implemented by another interface isn't a possible type —
1051
+ # only the objects underneath it are, and each names the interface itself.
1052
+ def read_possible(defn)
1053
+ defn.directives.each do |directive|
1054
+ name = subgraph(directive) or next
1055
+ case directive.name
1056
+ when "join__unionMember"
1057
+ member = argument(directive, "member") or next
1058
+ ((@possible[defn.name] ||= {})[name] ||= []) << member
1059
+ when "join__implements"
1060
+ next unless defn.is_a?(GraphQL::Language::Nodes::ObjectTypeDefinition)
1061
+
1062
+ iface = argument(directive, "interface") or next
1063
+ ((@possible[iface] ||= {})[name] ||= []) << defn.name
1064
+ end
1065
+ end
1066
+ end
1067
+
1068
+ def read_types
1069
+ @document.definitions.each do |defn|
1070
+ note_unknown(defn, defn.respond_to?(:name) ? defn.name : "schema")
1071
+ next unless defn.respond_to?(:name) && defn.respond_to?(:directives)
1072
+ next if defn.name.start_with?("join__", "link__", "core__")
1073
+
1074
+ joins = defn.directives.select { |d| d.name == "join__type" }
1075
+ @declared_in[defn.name] = joins.filter_map { |d| subgraph(d) }.uniq
1076
+ @keys[defn.name] = read_keys(joins)
1077
+ read_possible(defn)
1078
+
1079
+ as_object = joins.select { |d| argument(d, "isInterfaceObject") == true }
1080
+ @interface_objects[defn.name] = as_object.filter_map { |d| subgraph(d) } if as_object.any?
1081
+
1082
+ read_fields(defn)
1083
+ end
1084
+ end
1085
+
1086
+ def read_keys(joins)
1087
+ joins.each_with_object({}) do |directive, acc|
1088
+ name = subgraph(directive) or next
1089
+ acc[name] ||= []
1090
+ key = argument(directive, "key")
1091
+ acc[name] << self.class.parse_field_set(key) if key && argument(directive, "resolvable") != false
1092
+ end
1093
+ end
1094
+
1095
+ def read_fields(defn)
1096
+ return unless defn.respond_to?(:fields) && defn.fields
1097
+
1098
+ @field_names[defn.name] = defn.fields.map(&:name).to_set
1099
+ @fields[defn.name] = defn.fields.filter_map do |field|
1100
+ note_unknown(field, "#{defn.name}.#{field.name}")
1101
+ applied = field.directives.select { |d| d.name == "join__field" }
1102
+ next if applied.empty? # the supergraph says nothing — see owners
1103
+
1104
+ external, resolvable = applied.partition do |d|
1105
+ argument(d, "external") == true || argument(d, "usedOverridden") == true
1106
+ end
1107
+
1108
+ [field.name, Field.new(
1109
+ resolvable.filter_map { |d| subgraph(d) },
1110
+ external.filter_map { |d| subgraph(d) },
1111
+ resolvable.filter_map { |d| argument(d, "requires") }.first,
1112
+ resolvable.filter_map { |d| argument(d, "provides") }.first,
1113
+ applied.filter_map { |d| argument(d, "override") }.first,
1114
+ )]
1115
+ end.to_h
1116
+ end
1117
+
1118
+ def note_unknown(node, where)
1119
+ return unless node.respond_to?(:directives) && node.directives
1120
+
1121
+ node.directives.each do |directive|
1122
+ next unless directive.name.start_with?("join__")
1123
+ next if KNOWN.include?(directive.name)
1124
+
1125
+ @unsupported << "#{where} applies @#{directive.name}, which this table doesn't read"
1126
+ end
1127
+ end
1128
+
1129
+ # the subgraph NAME a directive's graph: argument points at
1130
+ def subgraph(directive) = @names[argument(directive, "graph")]
1131
+
1132
+ def argument(directive, name)
1133
+ return unless directive
1134
+
1135
+ value = directive.arguments.find { |arg| arg.name == name }&.value
1136
+ value.is_a?(GraphQL::Language::Nodes::Enum) ? value.name : value
1137
+ end
1138
+ end
380
1139
  end