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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1447 -1
- data/Gemfile +8 -0
- data/Gemfile.lock +151 -2
- data/README.md +20 -6
- data/docs/alternatives.md +201 -0
- data/docs/cassettes.md +17 -1
- data/docs/errors.md +382 -17
- data/docs/federation.md +469 -63
- data/docs/generated_modules.md +231 -15
- data/docs/getting_started.md +497 -104
- data/docs/i18n.md +234 -0
- data/docs/logging.md +160 -24
- data/docs/real_world.md +28 -0
- data/docs/scalars.md +190 -26
- data/docs/testing.md +457 -58
- data/docs/transports.md +164 -19
- data/docs/upgrading.md +328 -3
- data/graph_weaver.gemspec +7 -0
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +47 -10
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +404 -197
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/errors.rb +264 -34
- data/lib/graph_weaver/federation.rb +119 -26
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +17 -11
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +78 -0
- data/lib/graph_weaver/internal/headers.rb +51 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +40 -4
- data/lib/graph_weaver/internal.rb +183 -1
- data/lib/graph_weaver/log_subscriber.rb +66 -0
- data/lib/graph_weaver/logging.rb +136 -12
- data/lib/graph_weaver/query_module.rb +36 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +33 -5
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +221 -49
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +6 -5
- data/lib/graph_weaver/testing/endpoint.rb +106 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +133 -44
- data/lib/graph_weaver/testing/router.rb +58 -11
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +46 -4
- data/lib/graph_weaver/transport.rb +109 -26
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +474 -106
- 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
|
|
243
|
-
#
|
|
244
|
-
#
|
|
245
|
-
|
|
246
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
296
|
+
spellings = subgraph_spellings(doc)
|
|
261
297
|
|
|
262
298
|
directives = SUBGRAPH_DIRECTIVE_DEFS.filter_map do |name, defn|
|
|
263
|
-
applied =
|
|
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(
|
|
272
|
-
|
|
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(
|
|
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,
|
|
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
|
|
304
|
-
# entity it doesn't own
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
730
|
-
# overrides
|
|
731
|
-
# dump's recorded url.
|
|
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
|
-
#
|
|
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
|
|
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
|
-
"(
|
|
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
|
-
|
|
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
|
|
943
|
-
# @join__field
|
|
944
|
-
# the directive when it has nothing to
|
|
945
|
-
# supergraph spec's way of saying
|
|
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
|
-
|
|
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
|