graph_weaver 0.6.1 → 0.7.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1447 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +20 -6
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +497 -104
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +28 -0
  15. data/docs/scalars.md +190 -26
  16. data/docs/testing.md +457 -58
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +328 -3
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +404 -197
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +40 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +474 -106
  70. metadata +56 -1
@@ -127,10 +127,30 @@ module GraphWeaver::SchemaLoader
127
127
  private_class_method :build_sdl
128
128
 
129
129
  def self.build_introspection(result)
130
- build(:introspection) { GraphQL::Schema.from_introspection(result) }
130
+ build(:introspection) { GraphQL::Schema.from_introspection(result) }.tap do |schema|
131
+ restore_one_of!(schema, result)
132
+ end
131
133
  end
132
134
  private_class_method :build_introspection
133
135
 
136
+ # graphql-ruby's loader drops isOneOf, so an input object that arrived
137
+ # @oneOf comes back as an ordinary one — and ONE_OF, the only thing that
138
+ # enforces it, is never emitted. SDL dumps keep the directive themselves;
139
+ # this is the introspection path catching up. Applied after the load, which
140
+ # is when the arguments one_of validates against exist.
141
+ def self.restore_one_of!(schema, result)
142
+ types = result.dig("data", "__schema", "types")
143
+ return unless types
144
+
145
+ types.each do |type|
146
+ next unless type["isOneOf"]
147
+
148
+ loaded = schema.get_type(type["name"])
149
+ loaded.one_of if loaded.respond_to?(:one_of)
150
+ end
151
+ end
152
+ private_class_method :restore_one_of!
153
+
134
154
  # Which artifact an SDL string is — it decides both the normalizing it
135
155
  # needs and what to say when it won't build.
136
156
  def self.sdl_kind(sdl)
@@ -223,7 +243,6 @@ module GraphWeaver::SchemaLoader
223
243
  "@authenticated" => "directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM",
224
244
  "@requiresScopes" => "directive @requiresScopes(scopes: [[federation__Scope!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM",
225
245
  "@policy" => "directive @policy(policies: [[federation__Policy!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM",
226
- "@link" => "directive @link(url: String!, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA",
227
246
  }.freeze
228
247
 
229
248
  # The types those definitions reference — injected only alongside a
@@ -234,45 +253,93 @@ module GraphWeaver::SchemaLoader
234
253
  "federation__FieldSet" => "scalar federation__FieldSet",
235
254
  "federation__Scope" => "scalar federation__Scope",
236
255
  "federation__Policy" => "scalar federation__Policy",
237
- "link__Import" => "scalar link__Import",
238
- "link__Purpose" => "enum link__Purpose { SECURITY EXECUTION }",
239
256
  }.freeze
240
257
  private_constant :SUBGRAPH_DIRECTIVE_DEFS, :SUBGRAPH_HELPER_TYPES
241
258
 
242
- # A fed-2 subgraph that @links the spec under a namespace `as: "fed"`,
243
- # and "federation" is the default applies every non-imported directive
244
- # under it: @federation__key rather than @key.
245
- def self.subgraph_namespace(sdl)
246
- sdl[/#{SUBGRAPH_LINK}[^)]*\bas:\s*"([^"]+)"/, 1] || "federation"
259
+ # A fed-2 subgraph's federation @link, as a plain argument hash. One header
260
+ # settles both spellings a directive can arrive under: `as:` namespaces the
261
+ # whole spec (@fed__key), each `import:` entry binds one directive in the
262
+ # root namespace, possibly renamed. Read rather than matched — an
263
+ # `import: [{name: "@key", as: "@primaryKey"}]` entry has an `as:` of its
264
+ # own, which a regex over the header can't tell from the spec's.
265
+ def self.federation_link(doc)
266
+ link_declarations(doc).find { |args| spec_name(args["url"] || args["feature"]) == "federation" }
267
+ end
268
+ private_class_method :federation_link
269
+
270
+ # What this subgraph calls each federation directive: the local name it
271
+ # imported it under, else the namespace's (@federation__key), else the bare
272
+ # spec name, which is fed-1 and a fed-2 plain `import: ["@key"]` alike.
273
+ # Ordered — the first spelling the file actually applies wins.
274
+ def self.subgraph_spellings(doc)
275
+ link = federation_link(doc)
276
+ namespace = (link && link["as"].is_a?(String)) ? link["as"] : "federation"
277
+ aliases = imports(link && link["import"]).to_h
278
+
279
+ SUBGRAPH_DIRECTIVE_DEFS.keys.to_h do |name|
280
+ [name, [aliases[name], name, "@#{namespace}__#{name.delete_prefix("@")}"].compact.uniq]
281
+ end
247
282
  end
248
- private_class_method :subgraph_namespace
283
+ private_class_method :subgraph_spellings
249
284
 
250
285
  # Prepend the definitions this subgraph applies but doesn't declare, under
251
286
  # whichever name it applies them by. Only the missing ones — a duplicate
252
287
  # definition is a hard error in graphql-ruby, and a subgraph spelling out
253
288
  # its own @key (fed-1 style, or a differing shape) must win.
254
289
  def self.add_subgraph_definitions(sdl)
255
- defined = GraphQL.parse(sdl).definitions.filter_map do |defn|
290
+ doc = GraphQL.parse(sdl)
291
+ defined = doc.definitions.filter_map do |defn|
256
292
  next unless defn.respond_to?(:name)
257
293
 
258
294
  defn.is_a?(GraphQL::Language::Nodes::DirectiveDefinition) ? "@#{defn.name}" : defn.name
259
295
  end.to_set
260
- namespace = subgraph_namespace(sdl)
296
+ spellings = subgraph_spellings(doc)
261
297
 
262
298
  directives = SUBGRAPH_DIRECTIVE_DEFS.filter_map do |name, defn|
263
- applied = [name, "@#{namespace}__#{name.delete_prefix("@")}"]
264
- .find { |as| !defined.include?(as) && sdl.match?(/#{as}\b/) }
299
+ applied = spellings.fetch(name).find { |as| !defined.include?(as) && sdl.match?(/#{as}\b/) }
265
300
  applied && defn.sub(name, applied)
266
301
  end
267
302
  types = SUBGRAPH_HELPER_TYPES
268
303
  .select { |name, _| !defined.include?(name) && directives.any? { |defn| defn.include?(name) } }
269
304
  .values
270
305
 
271
- added = types + directives + entity_plumbing(sdl, namespace, defined)
272
- added.empty? ? sdl : "#{added.join("\n")}\n\n#{sdl}"
306
+ added = types + directives + entity_plumbing(doc, defined, spellings.fetch("@key"))
307
+ body = strip_link_header(sdl, doc)
308
+ added.empty? ? body : "#{added.join("\n")}\n\n#{body}"
273
309
  end
274
310
  private_class_method :add_subgraph_definitions
275
311
 
312
+ # The composition header says which spec a file's directives come from and
313
+ # under what names. That's metadata about the FILE, not part of the graph it
314
+ # describes — it is read (see subgraph_spellings) and then dropped, as the
315
+ # supergraph path drops its own. Dropping it is also what makes the spec's
316
+ # own aliasing loadable: `import:` is a `scalar link__Import`, and
317
+ # graphql-ruby's schema builder walks an object entry
318
+ # (`{name: "@key", as: "@primaryKey"}`) into a type that has no arguments.
319
+ #
320
+ # The original text is returned untouched when there's no header, so a
321
+ # fed-1 subgraph and a plain schema are never reprinted.
322
+ def self.strip_link_header(sdl, doc)
323
+ headers = doc.definitions.select do |defn|
324
+ (defn.is_a?(GraphQL::Language::Nodes::SchemaDefinition) ||
325
+ defn.is_a?(GraphQL::Language::Nodes::SchemaExtension)) &&
326
+ defn.directives.any? { |d| LINK_DIRECTIVES.include?(d.name) }
327
+ end
328
+ return sdl if headers.empty?
329
+
330
+ definitions = doc.definitions.filter_map do |defn|
331
+ next defn unless headers.include?(defn)
332
+
333
+ kept = defn.directives.reject { |d| LINK_DIRECTIVES.include?(d.name) }
334
+ # `extend schema` with nothing left in it isn't a definition any more
335
+ next if kept.empty? && [defn.query, defn.mutation, defn.subscription].all?(&:nil?)
336
+
337
+ defn.merge(directives: kept)
338
+ end
339
+ GraphQL::Language::Nodes::Document.new(definitions:).to_query_string
340
+ end
341
+ private_class_method :strip_link_header
342
+
276
343
  # The entity resolver every subgraph serves — and which no subgraph SDL
277
344
  # contains: `_service { sdl }` and `rover subgraph fetch` both print the
278
345
  # published schema, where the plumbing is implicit. Supply it so an
@@ -280,10 +347,9 @@ module GraphWeaver::SchemaLoader
280
347
  # (a supergraph doesn't describe `_entities` at all). `_Entity` is the
281
348
  # union of the file's own @key'd types, so it stays accurate per subgraph.
282
349
  # https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/reference/subgraph-spec
283
- def self.entity_plumbing(sdl, namespace, defined)
284
- doc = GraphQL.parse(sdl)
350
+ def self.entity_plumbing(doc, defined, key_spellings)
285
351
  root = query_root_name(doc)
286
- entities = entity_names(doc, namespace)
352
+ entities = entity_names(doc, key_spellings)
287
353
  return [] if entities.empty? || root.nil? || defined.include?("_Any")
288
354
 
289
355
  [
@@ -300,10 +366,11 @@ module GraphWeaver::SchemaLoader
300
366
 
301
367
  # The object types this subgraph resolves as entities: the ones it applies
302
368
  # @key to, under whichever name it applies it by (@federation__key when the
303
- # spec is linked under a namespace). Type extensions count fed-1 spells an
304
- # entity it doesn't own as `extend type User @key(...)`.
305
- def self.entity_names(doc, namespace)
306
- key_names = ["key", "#{namespace}__key"]
369
+ # spec is linked under a namespace, @primaryKey when it was imported under
370
+ # that name). Type extensions count — fed-1 spells an entity it doesn't own
371
+ # as `extend type User @key(...)`.
372
+ def self.entity_names(doc, key_spellings)
373
+ key_names = key_spellings.map { |name| name.delete_prefix("@") }
307
374
 
308
375
  doc.definitions.filter_map do |defn|
309
376
  next unless defn.is_a?(GraphQL::Language::Nodes::ObjectTypeDefinition) ||
@@ -631,7 +698,7 @@ module GraphWeaver::SchemaLoader
631
698
  end
632
699
 
633
700
  result = GraphWeaver::Internal::Log.log_timed(:info, "introspected #{endpoint(transport)}") do
634
- transport.execute(GraphQL::Introspection.query, variables: {}).to_h
701
+ ask(transport)
635
702
  end
636
703
  if (errors = result["errors"])
637
704
  raise GraphWeaver::Error, "introspection failed: #{errors.inspect}"
@@ -646,7 +713,10 @@ module GraphWeaver::SchemaLoader
646
713
  "endpoint? got: #{result.inspect[0, 200]}"
647
714
  end
648
715
 
649
- schema = GraphQL::Schema.from_introspection(result)
716
+ # the same door a dump read back off disk comes in by, so a schema
717
+ # introspected now and one loaded from the file this writes are the same
718
+ # schema — @oneOf included
719
+ schema = build_introspection(result)
650
720
 
651
721
  if cache
652
722
  # the extension picks the format: .json is the verbatim wire
@@ -659,6 +729,10 @@ module GraphWeaver::SchemaLoader
659
729
  header = meta && "# graph_weaver: #{JSON.generate(meta)}\n\n"
660
730
  "#{header}#{schema.to_definition}"
661
731
  end
732
+ # the one place a dump is overwritten, so the one place that can stop a
733
+ # supergraph being traded for the API schema behind it
734
+ raise GraphWeaver::Error, recompose_hint(cache) if composed_dump?(cache) && !federation_sdl?(content)
735
+
662
736
  begin
663
737
  FileUtils.mkdir_p(File.dirname(cache))
664
738
  GraphWeaver::Internal::Util.atomic_write(cache, content)
@@ -675,12 +749,40 @@ module GraphWeaver::SchemaLoader
675
749
  schema
676
750
  end
677
751
 
752
+ # The introspection result, asking for isOneOf and falling back without it.
753
+ #
754
+ # isOneOf is the only thing that says an input object is @oneOf, and
755
+ # graphql-ruby leaves it out unless asked — but it is newer than plenty of
756
+ # servers, and one that doesn't define it REFUSES the query outright
757
+ # (Hasura: "field 'isOneOf' not found in type: '__Type'"). So ask, and ask
758
+ # the baseline query rather than give up. The second request costs one round
759
+ # trip on exactly the servers whose answer was going to be an error anyway.
760
+ def self.ask(transport)
761
+ result = transport.execute(GraphQL::Introspection.query(include_is_one_of: true), variables: {}).to_h
762
+ return result if result["errors"].nil? && result.dig("data", "__schema")
763
+
764
+ transport.execute(GraphQL::Introspection.query, variables: {}).to_h
765
+ end
766
+ private_class_method :ask
767
+
678
768
  # What to call the thing we introspected, for a log line or an error: its
679
- # url when it has one, else the class (a schema class, a fake).
769
+ # url when it has one, else the class (a schema class, a fake). A
770
+ # graphql-ruby schema class fills the same slot a transport does, and its
771
+ # own name is what a report has to say — `.class` answers "Class".
680
772
  def self.endpoint(transport)
681
- (transport.respond_to?(:url) && transport.url) || transport.class
773
+ return GraphWeaver::Internal::Endpoint.safe(transport.url) if transport.respond_to?(:url) && transport.url
774
+
775
+ transport.is_a?(Module) ? (transport.name || transport.to_s) : transport.class
776
+ end
777
+
778
+ # Whether `source` names a dump file rather than being SDL or introspection
779
+ # content — by extension, which is how load_path reads one anyway. The only
780
+ # form of the question askable before the file exists, which is what
781
+ # `schema:refresh` needs to write the first one.
782
+ def self.dump_path?(source)
783
+ source = source.to_path if source.respond_to?(:to_path)
784
+ CACHE_EXTENSIONS.include?(File.extname(source.to_s))
682
785
  end
683
- private_class_method :endpoint
684
786
 
685
787
  # The conventional schema dump, whatever its format: schema_path or the
686
788
  # first sibling extension that exists. nil when none is on disk.
@@ -726,9 +828,11 @@ module GraphWeaver::SchemaLoader
726
828
  private_class_method :auth_env
727
829
 
728
830
  # Re-introspect a dump's source and compare — a {SchemaDiff} naming what
729
- # moved, empty when the server still matches what's on disk. transport:
730
- # overrides the transport (auth etc); by default one is built from the
731
- # dump's recorded url. Wired up as `rake graph_weaver:schema:diff`.
831
+ # moved, empty when the source still matches what's on disk. transport:
832
+ # overrides what to ask (auth etc); by default one is built from the
833
+ # dump's recorded url. A graphql-ruby schema class fills that slot too,
834
+ # which is how an app that serves its own schema diffs the dump against
835
+ # the code behind it. Wired up as `rake graph_weaver:schema:diff`.
732
836
  def self.diff(path, transport: nil)
733
837
  transport ||= source_transport(path)
734
838
  fresh = introspect(transport)
@@ -736,34 +840,77 @@ module GraphWeaver::SchemaLoader
736
840
  GraphWeaver::SchemaDiff.new(load(path), fresh, source: path, target: endpoint(transport).to_s)
737
841
  end
738
842
 
739
- # Re-introspect and rewrite the local dump, returning [path, url].
843
+ # Rewrite the local dump from the source behind it, returning
844
+ # [path, source]. The dump is the contract generation reads; this rewrites
845
+ # it from whatever the schema actually is — re-introspecting a url, or
846
+ # rebuilding from schema:, a graphql-ruby class this process runs (the
847
+ # same duck-typed slot a transport fills, so introspection asks it
848
+ # directly and nothing touches the network).
849
+ #
740
850
  # url: defaults to the one the dump recorded, so a refresh needs no
741
851
  # arguments once a dump exists — and passing one bootstraps the first
742
852
  # dump, which is what `rails g graph_weaver:install` does.
853
+ # path: the dump to rewrite, defaulting to the conventional one — a graph
854
+ # that names its own dump passes it.
743
855
  # auth_env: the ENV var holding the token — defaults to whichever the
744
856
  # dump recorded, so `--auth MY_TOKEN` keeps working on every later
745
857
  # refresh without being repeated. auth: passes a token directly.
746
- def self.refresh!(url: nil, auth_env: nil, auth: nil)
747
- path = locate_path
858
+ def self.refresh!(url: nil, auth_env: nil, auth: nil, schema: nil, path: nil)
859
+ path ||= locate_path
860
+
861
+ # ttl: 0 throughout — an existing dump never counts as fresh, a refresh
862
+ # always rebuilds. The extension picks the format, so a repo that keeps
863
+ # SDL keeps SDL.
864
+ if schema
865
+ introspect(schema, cache: path || GraphWeaver.schema_path, ttl: 0)
866
+ return [path || GraphWeaver.schema_path, endpoint(schema)]
867
+ end
868
+
748
869
  url ||= path && provenance(path)&.dig("url")
749
870
  raise GraphWeaver::Error, refresh_hint(path) unless url
750
871
 
751
872
  path ||= GraphWeaver.schema_path
752
873
  auth_env ||= self.auth_env(path)
753
874
  auth ||= ENV[auth_env]
754
- # ttl: 0 — an existing dump never counts as fresh, a refresh always refetches
755
875
  introspect(GraphWeaver.new(url, auth:).transport, cache: path, ttl: 0, auth_env:)
756
876
  [path, url]
757
877
  end
758
878
 
759
879
  def self.refresh_hint(path)
880
+ # a supergraph records no url because none could serve it — saying
881
+ # "pass one" sends a federated app to the overwrite composed_dump? stops
882
+ return recompose_hint(path) if composed_dump?(path)
883
+
760
884
  missing = path ? "#{path} records no source url" : "no schema dump at #{GraphWeaver.schema_path}"
761
885
  "#{missing} — pass one: rake graph_weaver:schema:refresh URL=https://api.example.com/graphql " \
762
- "(a dump taken from a schema class is rebuilt from code, not re-fetched see " \
763
- "docs/getting_started.md#your-apps-own-schema-in-process)"
886
+ "(if this app serves the schema itself, point GraphWeaver.client at the class and the dump is " \
887
+ "rebuilt from it — see docs/getting_started.md#your-apps-own-schema-in-process)"
764
888
  end
765
889
  private_class_method :refresh_hint
766
890
 
891
+ # A dump that carries the @join__* routing table: what `rover supergraph
892
+ # compose` emits, and the only artifact that says which subgraph resolves
893
+ # what. Asked of the bytes on disk, which is the only form of the question
894
+ # available before something overwrites them.
895
+ def self.composed_dump?(path)
896
+ return false unless path
897
+
898
+ resolved = GraphWeaver::Internal::Util.resolve(path)
899
+ File.exist?(resolved) && federation_sdl?(File.read(resolved))
900
+ end
901
+ private_class_method :composed_dump?
902
+
903
+ # Composition is the only thing that rebuilds a supergraph: introspection
904
+ # answers with the API schema, which is the merged shape minus the routing
905
+ # table, so refreshing one from a url replaces the contract with a strictly
906
+ # smaller artifact and reports success.
907
+ def self.recompose_hint(path)
908
+ "#{GraphWeaver::Internal::Util.relative(path)} is a composed supergraph; introspection returns " \
909
+ "the API schema, not the @join__* routing table — recompose it (rover supergraph compose) " \
910
+ "and check the result in, instead of refreshing it"
911
+ end
912
+ private_class_method :recompose_hint
913
+
767
914
  # A transport to the dump's recorded url, authenticated from whichever ENV
768
915
  # var the dump named (else DEFAULT_AUTH_ENV). The single way to reach a
769
916
  # dump's own server — building one at a call site is how `--auth MY_TOKEN`
@@ -783,12 +930,13 @@ module GraphWeaver::SchemaLoader
783
930
  # re-verified later — a parsable header comment in SDL, a
784
931
  # "graph_weaver" sibling key in introspection JSON (from_introspection
785
932
  # reads only "data"). nil when the transport has no url (schema
786
- # classes, fakes).
933
+ # classes, fakes). The url is recorded bare: a dump is committed, and
934
+ # re-introspection authenticates from auth_env, not from the url.
787
935
  def self.stamp(transport, auth_env = nil)
788
936
  return unless transport.respond_to?(:url) && transport.url
789
937
 
790
938
  require "time"
791
- meta = { "url" => transport.url, "introspected_at" => Time.now.utc.iso8601 }
939
+ meta = { "url" => GraphWeaver::Internal::Endpoint.bare(transport.url), "introspected_at" => Time.now.utc.iso8601 }
792
940
  # only the non-default var is worth recording — auth_env falls back to
793
941
  # DEFAULT_AUTH_ENV, so an unannotated dump reads the same either way
794
942
  meta["auth_env"] = auth_env if auth_env && auth_env != DEFAULT_AUTH_ENV
@@ -868,6 +1016,15 @@ module GraphWeaver::SchemaLoader
868
1016
  RoutingTable.new(sdl)
869
1017
  end
870
1018
 
1019
+ # Whether this source carries a routing table — the question
1020
+ # {routing_table} answers by refusing when it doesn't. A predicate rather
1021
+ # than a rescue, because every GraphWeaver::Error writes a warn line as it
1022
+ # is *constructed*: asking by exception made every non-federated app log
1023
+ # "no routing table here" once per process, about a table it never wanted.
1024
+ def self.routing_table?(source)
1025
+ !source.is_a?(Hash) && federation_sdl?(supergraph_sdl(source))
1026
+ end
1027
+
871
1028
  def self.supergraph_sdl(source)
872
1029
  if source.is_a?(Hash)
873
1030
  raise GraphWeaver::Error,
@@ -894,7 +1051,11 @@ module GraphWeaver::SchemaLoader
894
1051
  # the migration marker verbatim. `contextual` names the arguments a
895
1052
  # @fromContext fills from an ancestor selection (federation 2.8) — a fetch
896
1053
  # has to supply them, so whoever plans one needs to know they exist.
897
- Field = Struct.new(:graphs, :external, :requires, :provides, :override, :contextual)
1054
+ # `override_label` is federation 2.7's progressive @override(label:): both
1055
+ # subgraphs stay resolvable and the label is the rollout rule that decides
1056
+ # between them, so a router that can't evaluate it has to know it's there.
1057
+ Field = Struct.new(:graphs, :external, :requires, :provides, :override, :contextual,
1058
+ :override_label)
898
1059
 
899
1060
  # Every @join__ directive this table understands. One it doesn't is a
900
1061
  # federation construct nobody has taught it to read, and it lands in
@@ -928,6 +1089,7 @@ module GraphWeaver::SchemaLoader
928
1089
  @keys = {} # "User" => { "accounts" => [["id"]] }
929
1090
  @fields = {} # "User" => { "reviews" => Field }
930
1091
  @field_names = {} # "User" => Set["id", "username"]
1092
+ @signatures = {} # "User" => { "reviews" => "[Review!]!" }
931
1093
  @abstract = {} # "FeedItem" => ["Announcement", "Review"]
932
1094
  @possible = {} # "FeedItem" => { "reviews" => ["Announcement", "Review"] }
933
1095
  @unsupported = []
@@ -939,17 +1101,20 @@ module GraphWeaver::SchemaLoader
939
1101
  @subgraphs = @names.values.freeze
940
1102
  end
941
1103
 
942
- # Which subgraphs can resolve Type.field, by name. A field with no
943
- # @join__field at all lives wherever its type does the composer omits
944
- # the directive when it has nothing to say, and that omission is the
945
- # supergraph spec's way of saying "everywhere".
1104
+ # Which subgraphs can resolve Type.field, by name: exactly the ones its
1105
+ # @join__field names. A field with no @join__field at all lives wherever
1106
+ # its type does — the composer omits the directive when it has nothing to
1107
+ # say, and that omission is the supergraph spec's way of saying
1108
+ # "everywhere".
1109
+ #
1110
+ # One directive naming no subgraph therefore routes to none. Apollo
1111
+ # writes that on an @external/@usedOverridden copy — a reference, not a
1112
+ # resolver — and bare, with no arguments at all, on a concrete
1113
+ # implementer's copy of a field really contributed through
1114
+ # @interfaceObject elsewhere.
946
1115
  def owners(type_name, field_name)
947
1116
  field = self.field(type_name, field_name)
948
- return declared_in(type_name) if field.nil?
949
- return field.graphs if field.graphs.any?
950
-
951
- # declared only as @external/@usedOverridden: a reference, not a resolver
952
- field.external.any? ? [] : declared_in(type_name)
1117
+ field.nil? ? declared_in(type_name) : field.graphs
953
1118
  end
954
1119
 
955
1120
  # The routing for Type.field, or nil when the supergraph says nothing
@@ -973,6 +1138,11 @@ module GraphWeaver::SchemaLoader
973
1138
  # there.
974
1139
  def declared_fields(type_name) = @field_names[type_name]&.to_a || []
975
1140
 
1141
+ # The type the supergraph gives Type.field, printed the way SDL prints it
1142
+ # ("String!", "[Review!]!") — so it compares directly against a loaded
1143
+ # schema's `to_type_signature`. nil for a coordinate it doesn't carry.
1144
+ def signature(type_name, field_name) = @signatures.dig(type_name, field_name)
1145
+
976
1146
  # Whether the supergraph carries this coordinate at all — a type, or a
977
1147
  # field on it. `owners`/`fields` answer who resolves what the supergraph
978
1148
  # has; this answers whether it has it, which is the question a local
@@ -1151,6 +1321,7 @@ module GraphWeaver::SchemaLoader
1151
1321
  return unless defn.respond_to?(:fields) && defn.fields
1152
1322
 
1153
1323
  @field_names[defn.name] = defn.fields.map(&:name).to_set
1324
+ @signatures[defn.name] = defn.fields.to_h { |field| [field.name, field.type.to_query_string] }
1154
1325
  @fields[defn.name] = defn.fields.filter_map do |field|
1155
1326
  note_unknown(field, "#{defn.name}.#{field.name}")
1156
1327
  applied = field.directives.select { |d| d.name == "join__field" }
@@ -1167,6 +1338,7 @@ module GraphWeaver::SchemaLoader
1167
1338
  resolvable.filter_map { |d| argument(d, "provides") }.first,
1168
1339
  applied.filter_map { |d| argument(d, "override") }.first,
1169
1340
  applied.flat_map { |d| context_arguments(d) },
1341
+ applied.filter_map { |d| argument(d, "overrideLabel") }.first,
1170
1342
  )]
1171
1343
  end.to_h
1172
1344
  end