graph_weaver 0.6.1 → 0.7.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +8 -0
  3. data/Gemfile.lock +153 -4
  4. data/README.md +45 -79
  5. data/docs/alternatives.md +195 -0
  6. data/docs/cassettes.md +61 -50
  7. data/docs/editors.md +32 -47
  8. data/docs/errors.md +360 -103
  9. data/docs/federation.md +692 -473
  10. data/docs/generated_modules.md +441 -314
  11. data/docs/getting_started.md +370 -194
  12. data/docs/i18n.md +171 -0
  13. data/docs/logging.md +197 -50
  14. data/docs/real_world.md +42 -27
  15. data/docs/scalars.md +307 -176
  16. data/docs/testing.md +473 -220
  17. data/docs/transports.md +224 -151
  18. data/docs/upgrading.md +258 -305
  19. data/examples/README.md +38 -0
  20. data/examples/countries.rb +39 -0
  21. data/examples/federation.rb +62 -0
  22. data/examples/github/generate.rb +20 -0
  23. data/examples/github/generated/star_mutation.rb +126 -0
  24. data/examples/github/generated/stargazers_query.rb +232 -0
  25. data/examples/github/generated/starred_query.rb +151 -0
  26. data/examples/github/queries/star.graphql +8 -0
  27. data/examples/github/queries/stargazers.graphql +22 -0
  28. data/examples/github/queries/starred.graphql +11 -0
  29. data/examples/github/run.rb +43 -0
  30. data/examples/github/setup.rb +18 -0
  31. data/examples/rick_and_morty.rb +57 -0
  32. data/graph_weaver.gemspec +19 -3
  33. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  34. data/lib/graph_weaver/client.rb +69 -11
  35. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  36. data/lib/graph_weaver/codegen/emit.rb +98 -29
  37. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  38. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  39. data/lib/graph_weaver/codegen/registry.rb +175 -0
  40. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  41. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  42. data/lib/graph_weaver/codegen.rb +406 -195
  43. data/lib/graph_weaver/coerce.rb +155 -26
  44. data/lib/graph_weaver/context_seam.rb +54 -0
  45. data/lib/graph_weaver/errors.rb +284 -46
  46. data/lib/graph_weaver/federation.rb +129 -27
  47. data/lib/graph_weaver/graph.rb +315 -0
  48. data/lib/graph_weaver/hints.rb +100 -24
  49. data/lib/graph_weaver/in_process.rb +27 -15
  50. data/lib/graph_weaver/input_struct.rb +119 -32
  51. data/lib/graph_weaver/internal/endpoint.rb +80 -0
  52. data/lib/graph_weaver/internal/headers.rb +70 -0
  53. data/lib/graph_weaver/internal/overrides.rb +67 -5
  54. data/lib/graph_weaver/internal/planner.rb +45 -15
  55. data/lib/graph_weaver/internal/refusal.rb +49 -0
  56. data/lib/graph_weaver/internal/schemas.rb +23 -9
  57. data/lib/graph_weaver/internal/selection.rb +34 -0
  58. data/lib/graph_weaver/internal/server_input.rb +251 -0
  59. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  60. data/lib/graph_weaver/internal/unused.rb +287 -0
  61. data/lib/graph_weaver/internal/values.rb +40 -4
  62. data/lib/graph_weaver/internal.rb +249 -14
  63. data/lib/graph_weaver/log_subscriber.rb +74 -0
  64. data/lib/graph_weaver/logging.rb +163 -19
  65. data/lib/graph_weaver/query_module.rb +44 -3
  66. data/lib/graph_weaver/railtie.rb +237 -17
  67. data/lib/graph_weaver/representation.rb +55 -17
  68. data/lib/graph_weaver/result_struct.rb +90 -0
  69. data/lib/graph_weaver/retry.rb +45 -13
  70. data/lib/graph_weaver/rspec.rb +404 -93
  71. data/lib/graph_weaver/schema_loader.rb +266 -56
  72. data/lib/graph_weaver/tasks.rb +380 -89
  73. data/lib/graph_weaver/testing/cassette.rb +34 -10
  74. data/lib/graph_weaver/testing/endpoint.rb +107 -0
  75. data/lib/graph_weaver/testing/failure.rb +69 -12
  76. data/lib/graph_weaver/testing/fake_client.rb +164 -45
  77. data/lib/graph_weaver/testing/router.rb +64 -13
  78. data/lib/graph_weaver/testing.rb +200 -58
  79. data/lib/graph_weaver/transport/faraday.rb +41 -8
  80. data/lib/graph_weaver/transport/http.rb +48 -6
  81. data/lib/graph_weaver/transport.rb +134 -27
  82. data/lib/graph_weaver/version.rb +1 -1
  83. data/lib/graph_weaver.rb +495 -106
  84. metadata +71 -3
  85. data/CHANGELOG.md +0 -2355
@@ -1,6 +1,7 @@
1
1
  # typed: true
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "digest"
4
5
  require "fileutils"
5
6
  require "graphql"
6
7
  require "json"
@@ -127,10 +128,30 @@ module GraphWeaver::SchemaLoader
127
128
  private_class_method :build_sdl
128
129
 
129
130
  def self.build_introspection(result)
130
- build(:introspection) { GraphQL::Schema.from_introspection(result) }
131
+ build(:introspection) { GraphQL::Schema.from_introspection(result) }.tap do |schema|
132
+ restore_one_of!(schema, result)
133
+ end
131
134
  end
132
135
  private_class_method :build_introspection
133
136
 
137
+ # graphql-ruby's loader drops isOneOf, so an input object that arrived
138
+ # @oneOf comes back as an ordinary one — and ONE_OF, the only thing that
139
+ # enforces it, is never emitted. SDL dumps keep the directive themselves;
140
+ # this is the introspection path catching up. Applied after the load, which
141
+ # is when the arguments one_of validates against exist.
142
+ def self.restore_one_of!(schema, result)
143
+ types = result.dig("data", "__schema", "types")
144
+ return unless types
145
+
146
+ types.each do |type|
147
+ next unless type["isOneOf"]
148
+
149
+ loaded = schema.get_type(type["name"])
150
+ loaded.one_of if loaded.respond_to?(:one_of)
151
+ end
152
+ end
153
+ private_class_method :restore_one_of!
154
+
134
155
  # Which artifact an SDL string is — it decides both the normalizing it
135
156
  # needs and what to say when it won't build.
136
157
  def self.sdl_kind(sdl)
@@ -223,7 +244,6 @@ module GraphWeaver::SchemaLoader
223
244
  "@authenticated" => "directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM",
224
245
  "@requiresScopes" => "directive @requiresScopes(scopes: [[federation__Scope!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM",
225
246
  "@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
247
  }.freeze
228
248
 
229
249
  # The types those definitions reference — injected only alongside a
@@ -234,45 +254,93 @@ module GraphWeaver::SchemaLoader
234
254
  "federation__FieldSet" => "scalar federation__FieldSet",
235
255
  "federation__Scope" => "scalar federation__Scope",
236
256
  "federation__Policy" => "scalar federation__Policy",
237
- "link__Import" => "scalar link__Import",
238
- "link__Purpose" => "enum link__Purpose { SECURITY EXECUTION }",
239
257
  }.freeze
240
258
  private_constant :SUBGRAPH_DIRECTIVE_DEFS, :SUBGRAPH_HELPER_TYPES
241
259
 
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"
260
+ # A fed-2 subgraph's federation @link, as a plain argument hash. One header
261
+ # settles both spellings a directive can arrive under: `as:` namespaces the
262
+ # whole spec (@fed__key), each `import:` entry binds one directive in the
263
+ # root namespace, possibly renamed. Read rather than matched — an
264
+ # `import: [{name: "@key", as: "@primaryKey"}]` entry has an `as:` of its
265
+ # own, which a regex over the header can't tell from the spec's.
266
+ def self.federation_link(doc)
267
+ link_declarations(doc).find { |args| spec_name(args["url"] || args["feature"]) == "federation" }
247
268
  end
248
- private_class_method :subgraph_namespace
269
+ private_class_method :federation_link
270
+
271
+ # What this subgraph calls each federation directive: the local name it
272
+ # imported it under, else the namespace's (@federation__key), else the bare
273
+ # spec name, which is fed-1 and a fed-2 plain `import: ["@key"]` alike.
274
+ # Ordered — the first spelling the file actually applies wins.
275
+ def self.subgraph_spellings(doc)
276
+ link = federation_link(doc)
277
+ namespace = (link && link["as"].is_a?(String)) ? link["as"] : "federation"
278
+ aliases = imports(link && link["import"]).to_h
279
+
280
+ SUBGRAPH_DIRECTIVE_DEFS.keys.to_h do |name|
281
+ [name, [aliases[name], name, "@#{namespace}__#{name.delete_prefix("@")}"].compact.uniq]
282
+ end
283
+ end
284
+ private_class_method :subgraph_spellings
249
285
 
250
286
  # Prepend the definitions this subgraph applies but doesn't declare, under
251
287
  # whichever name it applies them by. Only the missing ones — a duplicate
252
288
  # definition is a hard error in graphql-ruby, and a subgraph spelling out
253
289
  # its own @key (fed-1 style, or a differing shape) must win.
254
290
  def self.add_subgraph_definitions(sdl)
255
- defined = GraphQL.parse(sdl).definitions.filter_map do |defn|
291
+ doc = GraphQL.parse(sdl)
292
+ defined = doc.definitions.filter_map do |defn|
256
293
  next unless defn.respond_to?(:name)
257
294
 
258
295
  defn.is_a?(GraphQL::Language::Nodes::DirectiveDefinition) ? "@#{defn.name}" : defn.name
259
296
  end.to_set
260
- namespace = subgraph_namespace(sdl)
297
+ spellings = subgraph_spellings(doc)
261
298
 
262
299
  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/) }
300
+ applied = spellings.fetch(name).find { |as| !defined.include?(as) && sdl.match?(/#{as}\b/) }
265
301
  applied && defn.sub(name, applied)
266
302
  end
267
303
  types = SUBGRAPH_HELPER_TYPES
268
304
  .select { |name, _| !defined.include?(name) && directives.any? { |defn| defn.include?(name) } }
269
305
  .values
270
306
 
271
- added = types + directives + entity_plumbing(sdl, namespace, defined)
272
- added.empty? ? sdl : "#{added.join("\n")}\n\n#{sdl}"
307
+ added = types + directives + entity_plumbing(doc, defined, spellings.fetch("@key"))
308
+ body = strip_link_header(sdl, doc)
309
+ added.empty? ? body : "#{added.join("\n")}\n\n#{body}"
273
310
  end
274
311
  private_class_method :add_subgraph_definitions
275
312
 
313
+ # The composition header says which spec a file's directives come from and
314
+ # under what names. That's metadata about the FILE, not part of the graph it
315
+ # describes — it is read (see subgraph_spellings) and then dropped, as the
316
+ # supergraph path drops its own. Dropping it is also what makes the spec's
317
+ # own aliasing loadable: `import:` is a `scalar link__Import`, and
318
+ # graphql-ruby's schema builder walks an object entry
319
+ # (`{name: "@key", as: "@primaryKey"}`) into a type that has no arguments.
320
+ #
321
+ # The original text is returned untouched when there's no header, so a
322
+ # fed-1 subgraph and a plain schema are never reprinted.
323
+ def self.strip_link_header(sdl, doc)
324
+ headers = doc.definitions.select do |defn|
325
+ (defn.is_a?(GraphQL::Language::Nodes::SchemaDefinition) ||
326
+ defn.is_a?(GraphQL::Language::Nodes::SchemaExtension)) &&
327
+ defn.directives.any? { |d| LINK_DIRECTIVES.include?(d.name) }
328
+ end
329
+ return sdl if headers.empty?
330
+
331
+ definitions = doc.definitions.filter_map do |defn|
332
+ next defn unless headers.include?(defn)
333
+
334
+ kept = defn.directives.reject { |d| LINK_DIRECTIVES.include?(d.name) }
335
+ # `extend schema` with nothing left in it isn't a definition any more
336
+ next if kept.empty? && [defn.query, defn.mutation, defn.subscription].all?(&:nil?)
337
+
338
+ defn.merge(directives: kept)
339
+ end
340
+ GraphQL::Language::Nodes::Document.new(definitions:).to_query_string
341
+ end
342
+ private_class_method :strip_link_header
343
+
276
344
  # The entity resolver every subgraph serves — and which no subgraph SDL
277
345
  # contains: `_service { sdl }` and `rover subgraph fetch` both print the
278
346
  # published schema, where the plumbing is implicit. Supply it so an
@@ -280,10 +348,9 @@ module GraphWeaver::SchemaLoader
280
348
  # (a supergraph doesn't describe `_entities` at all). `_Entity` is the
281
349
  # union of the file's own @key'd types, so it stays accurate per subgraph.
282
350
  # 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)
351
+ def self.entity_plumbing(doc, defined, key_spellings)
285
352
  root = query_root_name(doc)
286
- entities = entity_names(doc, namespace)
353
+ entities = entity_names(doc, key_spellings)
287
354
  return [] if entities.empty? || root.nil? || defined.include?("_Any")
288
355
 
289
356
  [
@@ -300,10 +367,11 @@ module GraphWeaver::SchemaLoader
300
367
 
301
368
  # The object types this subgraph resolves as entities: the ones it applies
302
369
  # @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"]
370
+ # spec is linked under a namespace, @primaryKey when it was imported under
371
+ # that name). Type extensions count — fed-1 spells an entity it doesn't own
372
+ # as `extend type User @key(...)`.
373
+ def self.entity_names(doc, key_spellings)
374
+ key_names = key_spellings.map { |name| name.delete_prefix("@") }
307
375
 
308
376
  doc.definitions.filter_map do |defn|
309
377
  next unless defn.is_a?(GraphQL::Language::Nodes::ObjectTypeDefinition) ||
@@ -615,7 +683,7 @@ module GraphWeaver::SchemaLoader
615
683
  # end
616
684
  # schema = GraphWeaver::SchemaLoader.load(json)
617
685
  def self.introspect(transport, cache: nil, ttl: nil, auth_env: nil)
618
- cache = cache_path(cache)
686
+ cache = cache_path(cache, (transport.url if transport.respond_to?(:url)))
619
687
 
620
688
  if cache
621
689
  # reuse whatever fresh dump is present, regardless of format —
@@ -631,7 +699,7 @@ module GraphWeaver::SchemaLoader
631
699
  end
632
700
 
633
701
  result = GraphWeaver::Internal::Log.log_timed(:info, "introspected #{endpoint(transport)}") do
634
- transport.execute(GraphQL::Introspection.query, variables: {}).to_h
702
+ ask(transport)
635
703
  end
636
704
  if (errors = result["errors"])
637
705
  raise GraphWeaver::Error, "introspection failed: #{errors.inspect}"
@@ -646,7 +714,10 @@ module GraphWeaver::SchemaLoader
646
714
  "endpoint? got: #{result.inspect[0, 200]}"
647
715
  end
648
716
 
649
- schema = GraphQL::Schema.from_introspection(result)
717
+ # the same door a dump read back off disk comes in by, so a schema
718
+ # introspected now and one loaded from the file this writes are the same
719
+ # schema — @oneOf included
720
+ schema = build_introspection(result)
650
721
 
651
722
  if cache
652
723
  # the extension picks the format: .json is the verbatim wire
@@ -659,6 +730,10 @@ module GraphWeaver::SchemaLoader
659
730
  header = meta && "# graph_weaver: #{JSON.generate(meta)}\n\n"
660
731
  "#{header}#{schema.to_definition}"
661
732
  end
733
+ # the one place a dump is overwritten, so the one place that can stop a
734
+ # supergraph being traded for the API schema behind it
735
+ raise GraphWeaver::Error, recompose_hint(cache) if composed_dump?(cache) && !federation_sdl?(content)
736
+
662
737
  begin
663
738
  FileUtils.mkdir_p(File.dirname(cache))
664
739
  GraphWeaver::Internal::Util.atomic_write(cache, content)
@@ -675,12 +750,44 @@ module GraphWeaver::SchemaLoader
675
750
  schema
676
751
  end
677
752
 
753
+ # The introspection result, asking for the newer fields and falling back
754
+ # without them.
755
+ #
756
+ # isOneOf is the only thing that says an input object is @oneOf, and
757
+ # specifiedByURL the only thing that says what a scalar's format is;
758
+ # graphql-ruby leaves both out unless asked — but they are newer than
759
+ # plenty of servers, and one that doesn't define them REFUSES the query
760
+ # outright (Hasura: "field 'isOneOf' not found in type: '__Type'"). So ask,
761
+ # and ask the baseline query rather than give up. The second request costs
762
+ # one round trip on exactly the servers whose answer was going to be an
763
+ # error anyway.
764
+ def self.ask(transport)
765
+ query = GraphQL::Introspection.query(include_is_one_of: true, include_specified_by_url: true)
766
+ result = transport.execute(query, variables: {}).to_h
767
+ return result if result["errors"].nil? && result.dig("data", "__schema")
768
+
769
+ transport.execute(GraphQL::Introspection.query, variables: {}).to_h
770
+ end
771
+ private_class_method :ask
772
+
678
773
  # 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).
774
+ # url when it has one, else the class (a schema class, a fake). A
775
+ # graphql-ruby schema class fills the same slot a transport does, and its
776
+ # own name is what a report has to say — `.class` answers "Class".
680
777
  def self.endpoint(transport)
681
- (transport.respond_to?(:url) && transport.url) || transport.class
778
+ return GraphWeaver::Internal::Endpoint.safe(transport.url) if transport.respond_to?(:url) && transport.url
779
+
780
+ transport.is_a?(Module) ? (transport.name || transport.to_s) : transport.class
781
+ end
782
+
783
+ # Whether `source` names a dump file rather than being SDL or introspection
784
+ # content — by extension, which is how load_path reads one anyway. The only
785
+ # form of the question askable before the file exists, which is what
786
+ # `schema:refresh` needs to write the first one.
787
+ def self.dump_path?(source)
788
+ source = source.to_path if source.respond_to?(:to_path)
789
+ CACHE_EXTENSIONS.include?(File.extname(source.to_s))
682
790
  end
683
- private_class_method :endpoint
684
791
 
685
792
  # The conventional schema dump, whatever its format: schema_path or the
686
793
  # first sibling extension that exists. nil when none is on disk.
@@ -726,9 +833,11 @@ module GraphWeaver::SchemaLoader
726
833
  private_class_method :auth_env
727
834
 
728
835
  # 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`.
836
+ # moved, empty when the source still matches what's on disk. transport:
837
+ # overrides what to ask (auth etc); by default one is built from the
838
+ # dump's recorded url. A graphql-ruby schema class fills that slot too,
839
+ # which is how an app that serves its own schema diffs the dump against
840
+ # the code behind it. Wired up as `rake graph_weaver:schema:diff`.
732
841
  def self.diff(path, transport: nil)
733
842
  transport ||= source_transport(path)
734
843
  fresh = introspect(transport)
@@ -736,34 +845,77 @@ module GraphWeaver::SchemaLoader
736
845
  GraphWeaver::SchemaDiff.new(load(path), fresh, source: path, target: endpoint(transport).to_s)
737
846
  end
738
847
 
739
- # Re-introspect and rewrite the local dump, returning [path, url].
848
+ # Rewrite the local dump from the source behind it, returning
849
+ # [path, source]. The dump is the contract generation reads; this rewrites
850
+ # it from whatever the schema actually is — re-introspecting a url, or
851
+ # rebuilding from schema:, a graphql-ruby class this process runs (the
852
+ # same duck-typed slot a transport fills, so introspection asks it
853
+ # directly and nothing touches the network).
854
+ #
740
855
  # url: defaults to the one the dump recorded, so a refresh needs no
741
856
  # arguments once a dump exists — and passing one bootstraps the first
742
857
  # dump, which is what `rails g graph_weaver:install` does.
858
+ # path: the dump to rewrite, defaulting to the conventional one — a graph
859
+ # that names its own dump passes it.
743
860
  # auth_env: the ENV var holding the token — defaults to whichever the
744
861
  # dump recorded, so `--auth MY_TOKEN` keeps working on every later
745
862
  # refresh without being repeated. auth: passes a token directly.
746
- def self.refresh!(url: nil, auth_env: nil, auth: nil)
747
- path = locate_path
863
+ def self.refresh!(url: nil, auth_env: nil, auth: nil, schema: nil, path: nil)
864
+ path ||= locate_path
865
+
866
+ # ttl: 0 throughout — an existing dump never counts as fresh, a refresh
867
+ # always rebuilds. The extension picks the format, so a repo that keeps
868
+ # SDL keeps SDL.
869
+ if schema
870
+ introspect(schema, cache: path || GraphWeaver.schema_path, ttl: 0)
871
+ return [path || GraphWeaver.schema_path, endpoint(schema)]
872
+ end
873
+
748
874
  url ||= path && provenance(path)&.dig("url")
749
875
  raise GraphWeaver::Error, refresh_hint(path) unless url
750
876
 
751
877
  path ||= GraphWeaver.schema_path
752
878
  auth_env ||= self.auth_env(path)
753
879
  auth ||= ENV[auth_env]
754
- # ttl: 0 — an existing dump never counts as fresh, a refresh always refetches
755
880
  introspect(GraphWeaver.new(url, auth:).transport, cache: path, ttl: 0, auth_env:)
756
881
  [path, url]
757
882
  end
758
883
 
759
884
  def self.refresh_hint(path)
885
+ # a supergraph records no url because none could serve it — saying
886
+ # "pass one" sends a federated app to the overwrite composed_dump? stops
887
+ return recompose_hint(path) if composed_dump?(path)
888
+
760
889
  missing = path ? "#{path} records no source url" : "no schema dump at #{GraphWeaver.schema_path}"
761
890
  "#{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)"
891
+ "(if this app serves the schema itself, point GraphWeaver.client at the class and the dump is " \
892
+ "rebuilt from it — see docs/getting_started.md#your-apps-own-schema-in-process)"
764
893
  end
765
894
  private_class_method :refresh_hint
766
895
 
896
+ # A dump that carries the @join__* routing table: what `rover supergraph
897
+ # compose` emits, and the only artifact that says which subgraph resolves
898
+ # what. Asked of the bytes on disk, which is the only form of the question
899
+ # available before something overwrites them.
900
+ def self.composed_dump?(path)
901
+ return false unless path
902
+
903
+ resolved = GraphWeaver::Internal::Util.resolve(path)
904
+ File.exist?(resolved) && federation_sdl?(File.read(resolved))
905
+ end
906
+ private_class_method :composed_dump?
907
+
908
+ # Composition is the only thing that rebuilds a supergraph: introspection
909
+ # answers with the API schema, which is the merged shape minus the routing
910
+ # table, so refreshing one from a url replaces the contract with a strictly
911
+ # smaller artifact and reports success.
912
+ def self.recompose_hint(path)
913
+ "#{GraphWeaver::Internal::Util.relative(path)} is a composed supergraph; introspection returns " \
914
+ "the API schema, not the @join__* routing table — recompose it (rover supergraph compose) " \
915
+ "and check the result in, instead of refreshing it"
916
+ end
917
+ private_class_method :recompose_hint
918
+
767
919
  # A transport to the dump's recorded url, authenticated from whichever ENV
768
920
  # var the dump named (else DEFAULT_AUTH_ENV). The single way to reach a
769
921
  # dump's own server — building one at a call site is how `--auth MY_TOKEN`
@@ -783,12 +935,13 @@ module GraphWeaver::SchemaLoader
783
935
  # re-verified later — a parsable header comment in SDL, a
784
936
  # "graph_weaver" sibling key in introspection JSON (from_introspection
785
937
  # reads only "data"). nil when the transport has no url (schema
786
- # classes, fakes).
938
+ # classes, fakes). The url is recorded bare: a dump is committed, and
939
+ # re-introspection authenticates from auth_env, not from the url.
787
940
  def self.stamp(transport, auth_env = nil)
788
941
  return unless transport.respond_to?(:url) && transport.url
789
942
 
790
943
  require "time"
791
- meta = { "url" => transport.url, "introspected_at" => Time.now.utc.iso8601 }
944
+ meta = { "url" => GraphWeaver::Internal::Endpoint.bare(transport.url), "introspected_at" => Time.now.utc.iso8601 }
792
945
  # only the non-default var is worth recording — auth_env falls back to
793
946
  # DEFAULT_AUTH_ENV, so an unannotated dump reads the same either way
794
947
  meta["auth_env"] = auth_env if auth_env && auth_env != DEFAULT_AUTH_ENV
@@ -800,23 +953,24 @@ module GraphWeaver::SchemaLoader
800
953
  private_constant :CACHE_EXTENSIONS
801
954
 
802
955
  # cache: true / :json / :graphql / :gql / a path => the file to write
803
- # (nil for no caching). Symbols and true anchor at GraphWeaver.schema_path —
804
- # the schema dump the generation workflow reads, so one file serves both
805
- # (introspect caches it, rake generate loads it).
806
- def self.cache_path(cache)
956
+ # (nil for no caching). A path is taken as given. Symbols and true anchor at
957
+ # GraphWeaver.schema_path — the schema dump the generation workflow reads,
958
+ # so one file serves both (introspect caches it, rake generate loads it)
959
+ # unless that dump belongs to a different endpoint; see {conventional_dump}.
960
+ def self.cache_path(cache, url = nil)
807
961
  # Rails.root.join(...) hands you a Pathname, as schema: and query: already take
808
962
  cache = cache.to_path if cache.respond_to?(:to_path)
809
963
  path = case cache
810
964
  when nil, false
811
965
  nil
812
966
  when true
813
- GraphWeaver.schema_path
967
+ conventional_dump(File.extname(GraphWeaver.schema_path), url)
814
968
  when Symbol
815
969
  unless CACHE_EXTENSIONS.include?(".#{cache}")
816
970
  raise ArgumentError, "cache: format must be :json, :graphql, or :gql, got #{cache.inspect}"
817
971
  end
818
972
 
819
- "#{strip_extension(GraphWeaver.schema_path)}.#{cache}"
973
+ conventional_dump(".#{cache}", url)
820
974
  else
821
975
  unless cache.end_with?(*CACHE_EXTENSIONS)
822
976
  raise ArgumentError, "cache: must be a .json or .graphql/.gql path, got #{cache}"
@@ -828,6 +982,38 @@ module GraphWeaver::SchemaLoader
828
982
  end
829
983
  private_class_method :cache_path
830
984
 
985
+ # Where an unnamed `cache:` lands. One rule: **the conventional dump, unless
986
+ # the dump there came from a different endpoint** — then a file of this
987
+ # client's own, named by a digest of its url and sitting beside it. A dump
988
+ # records the url it was introspected from, so this is answerable; without
989
+ # it, two clients at two origins both saying `cache: true` read and
990
+ # overwrote one file, and each was silently served the other's schema.
991
+ #
992
+ # A dump that records no url is nobody's in particular — hand-written, or
993
+ # committed by an older version — so it stays a hit for whoever asks.
994
+ def self.conventional_dump(extension, url)
995
+ base = strip_extension(GraphWeaver.schema_path)
996
+ conventional = base + extension
997
+ url = url && GraphWeaver::Internal::Endpoint.bare(url) # what a dump records
998
+ return conventional if url.nil? || !someone_elses?(conventional, url)
999
+
1000
+ "#{base}-#{Digest::SHA256.hexdigest(url)[0, 8]}#{extension}"
1001
+ end
1002
+ private_class_method :conventional_dump
1003
+
1004
+ # Whether a dump already at `path` (or a sibling format) says it came from
1005
+ # somewhere other than `url`. Nothing there yet is nobody's, so a cold
1006
+ # client still writes the conventional dump — which is what the documented
1007
+ # bootstrap does, and what an app with one client wants.
1008
+ def self.someone_elses?(path, url)
1009
+ existing = cache_candidates(path).find { |candidate| File.exist?(candidate) }
1010
+ return false unless existing
1011
+
1012
+ recorded = provenance(existing)&.dig("url")
1013
+ !recorded.nil? && recorded != url
1014
+ end
1015
+ private_class_method :someone_elses?
1016
+
831
1017
  # the requested path first, then its siblings in the other formats
832
1018
  def self.cache_candidates(path)
833
1019
  path = GraphWeaver::Internal::Util.resolve(path)
@@ -868,6 +1054,15 @@ module GraphWeaver::SchemaLoader
868
1054
  RoutingTable.new(sdl)
869
1055
  end
870
1056
 
1057
+ # Whether this source carries a routing table — the question
1058
+ # {routing_table} answers by refusing when it doesn't. A predicate rather
1059
+ # than a rescue, because every GraphWeaver::Error writes a warn line as it
1060
+ # is *constructed*: asking by exception made every non-federated app log
1061
+ # "no routing table here" once per process, about a table it never wanted.
1062
+ def self.routing_table?(source)
1063
+ !source.is_a?(Hash) && federation_sdl?(supergraph_sdl(source))
1064
+ end
1065
+
871
1066
  def self.supergraph_sdl(source)
872
1067
  if source.is_a?(Hash)
873
1068
  raise GraphWeaver::Error,
@@ -894,7 +1089,11 @@ module GraphWeaver::SchemaLoader
894
1089
  # the migration marker verbatim. `contextual` names the arguments a
895
1090
  # @fromContext fills from an ancestor selection (federation 2.8) — a fetch
896
1091
  # has to supply them, so whoever plans one needs to know they exist.
897
- Field = Struct.new(:graphs, :external, :requires, :provides, :override, :contextual)
1092
+ # `override_label` is federation 2.7's progressive @override(label:): both
1093
+ # subgraphs stay resolvable and the label is the rollout rule that decides
1094
+ # between them, so a router that can't evaluate it has to know it's there.
1095
+ Field = Struct.new(:graphs, :external, :requires, :provides, :override, :contextual,
1096
+ :override_label)
898
1097
 
899
1098
  # Every @join__ directive this table understands. One it doesn't is a
900
1099
  # federation construct nobody has taught it to read, and it lands in
@@ -928,6 +1127,7 @@ module GraphWeaver::SchemaLoader
928
1127
  @keys = {} # "User" => { "accounts" => [["id"]] }
929
1128
  @fields = {} # "User" => { "reviews" => Field }
930
1129
  @field_names = {} # "User" => Set["id", "username"]
1130
+ @signatures = {} # "User" => { "reviews" => "[Review!]!" }
931
1131
  @abstract = {} # "FeedItem" => ["Announcement", "Review"]
932
1132
  @possible = {} # "FeedItem" => { "reviews" => ["Announcement", "Review"] }
933
1133
  @unsupported = []
@@ -939,17 +1139,20 @@ module GraphWeaver::SchemaLoader
939
1139
  @subgraphs = @names.values.freeze
940
1140
  end
941
1141
 
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".
1142
+ # Which subgraphs can resolve Type.field, by name: exactly the ones its
1143
+ # @join__field names. A field with no @join__field at all lives wherever
1144
+ # its type does — the composer omits the directive when it has nothing to
1145
+ # say, and that omission is the supergraph spec's way of saying
1146
+ # "everywhere".
1147
+ #
1148
+ # One directive naming no subgraph therefore routes to none. Apollo
1149
+ # writes that on an @external/@usedOverridden copy — a reference, not a
1150
+ # resolver — and bare, with no arguments at all, on a concrete
1151
+ # implementer's copy of a field really contributed through
1152
+ # @interfaceObject elsewhere.
946
1153
  def owners(type_name, field_name)
947
1154
  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)
1155
+ field.nil? ? declared_in(type_name) : field.graphs
953
1156
  end
954
1157
 
955
1158
  # The routing for Type.field, or nil when the supergraph says nothing
@@ -973,6 +1176,11 @@ module GraphWeaver::SchemaLoader
973
1176
  # there.
974
1177
  def declared_fields(type_name) = @field_names[type_name]&.to_a || []
975
1178
 
1179
+ # The type the supergraph gives Type.field, printed the way SDL prints it
1180
+ # ("String!", "[Review!]!") — so it compares directly against a loaded
1181
+ # schema's `to_type_signature`. nil for a coordinate it doesn't carry.
1182
+ def signature(type_name, field_name) = @signatures.dig(type_name, field_name)
1183
+
976
1184
  # Whether the supergraph carries this coordinate at all — a type, or a
977
1185
  # field on it. `owners`/`fields` answer who resolves what the supergraph
978
1186
  # has; this answers whether it has it, which is the question a local
@@ -1151,6 +1359,7 @@ module GraphWeaver::SchemaLoader
1151
1359
  return unless defn.respond_to?(:fields) && defn.fields
1152
1360
 
1153
1361
  @field_names[defn.name] = defn.fields.map(&:name).to_set
1362
+ @signatures[defn.name] = defn.fields.to_h { |field| [field.name, field.type.to_query_string] }
1154
1363
  @fields[defn.name] = defn.fields.filter_map do |field|
1155
1364
  note_unknown(field, "#{defn.name}.#{field.name}")
1156
1365
  applied = field.directives.select { |d| d.name == "join__field" }
@@ -1167,6 +1376,7 @@ module GraphWeaver::SchemaLoader
1167
1376
  resolvable.filter_map { |d| argument(d, "provides") }.first,
1168
1377
  applied.filter_map { |d| argument(d, "override") }.first,
1169
1378
  applied.flat_map { |d| context_arguments(d) },
1379
+ applied.filter_map { |d| argument(d, "overrideLabel") }.first,
1170
1380
  )]
1171
1381
  end.to_h
1172
1382
  end