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.
- checksums.yaml +4 -4
- data/Gemfile +8 -0
- data/Gemfile.lock +153 -4
- data/README.md +45 -79
- data/docs/alternatives.md +195 -0
- data/docs/cassettes.md +61 -50
- data/docs/editors.md +32 -47
- data/docs/errors.md +360 -103
- data/docs/federation.md +692 -473
- data/docs/generated_modules.md +441 -314
- data/docs/getting_started.md +370 -194
- data/docs/i18n.md +171 -0
- data/docs/logging.md +197 -50
- data/docs/real_world.md +42 -27
- data/docs/scalars.md +307 -176
- data/docs/testing.md +473 -220
- data/docs/transports.md +224 -151
- data/docs/upgrading.md +258 -305
- data/examples/README.md +38 -0
- data/examples/countries.rb +39 -0
- data/examples/federation.rb +62 -0
- data/examples/github/generate.rb +20 -0
- data/examples/github/generated/star_mutation.rb +126 -0
- data/examples/github/generated/stargazers_query.rb +232 -0
- data/examples/github/generated/starred_query.rb +151 -0
- data/examples/github/queries/star.graphql +8 -0
- data/examples/github/queries/stargazers.graphql +22 -0
- data/examples/github/queries/starred.graphql +11 -0
- data/examples/github/run.rb +43 -0
- data/examples/github/setup.rb +18 -0
- data/examples/rick_and_morty.rb +57 -0
- data/graph_weaver.gemspec +19 -3
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +69 -11
- 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 +406 -195
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +284 -46
- data/lib/graph_weaver/federation.rb +129 -27
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +27 -15
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +80 -0
- data/lib/graph_weaver/internal/headers.rb +70 -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 +249 -14
- data/lib/graph_weaver/log_subscriber.rb +74 -0
- data/lib/graph_weaver/logging.rb +163 -19
- data/lib/graph_weaver/query_module.rb +44 -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 +45 -13
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +266 -56
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +34 -10
- data/lib/graph_weaver/testing/endpoint.rb +107 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +164 -45
- data/lib/graph_weaver/testing/router.rb +64 -13
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +48 -6
- data/lib/graph_weaver/transport.rb +134 -27
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +495 -106
- metadata +71 -3
- 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
|
|
243
|
-
#
|
|
244
|
-
#
|
|
245
|
-
|
|
246
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
297
|
+
spellings = subgraph_spellings(doc)
|
|
261
298
|
|
|
262
299
|
directives = SUBGRAPH_DIRECTIVE_DEFS.filter_map do |name, defn|
|
|
263
|
-
applied =
|
|
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(
|
|
272
|
-
|
|
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(
|
|
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,
|
|
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
|
|
304
|
-
# entity it doesn't own
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
730
|
-
# overrides
|
|
731
|
-
# dump's recorded url.
|
|
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
|
-
#
|
|
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
|
|
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
|
-
"(
|
|
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
|
|
804
|
-
# the schema dump the generation workflow reads,
|
|
805
|
-
# (introspect caches it, rake generate loads it)
|
|
806
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
943
|
-
# @join__field
|
|
944
|
-
# the directive when it has nothing to
|
|
945
|
-
# supergraph spec's way of saying
|
|
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
|
-
|
|
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
|