graph_weaver 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +537 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +29 -4
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +75 -16
  8. data/docs/federation.md +206 -155
  9. data/docs/generated_modules.md +223 -166
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +35 -5
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +196 -155
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +243 -22
  16. data/graph_weaver.gemspec +16 -2
  17. data/lib/generators/graph_weaver/install_generator.rb +31 -16
  18. data/lib/graph_weaver/client.rb +52 -15
  19. data/lib/graph_weaver/codegen/aliases.rb +15 -8
  20. data/lib/graph_weaver/codegen/emit.rb +107 -42
  21. data/lib/graph_weaver/codegen/enum_type.rb +4 -3
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
  24. data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
  25. data/lib/graph_weaver/codegen.rb +382 -105
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +57 -13
  28. data/lib/graph_weaver/federation.rb +10 -22
  29. data/lib/graph_weaver/hints.rb +76 -2
  30. data/lib/graph_weaver/in_process.rb +11 -8
  31. data/lib/graph_weaver/inflect.rb +2 -0
  32. data/lib/graph_weaver/input_struct.rb +115 -12
  33. data/lib/graph_weaver/internal/overrides.rb +101 -0
  34. data/lib/graph_weaver/internal/planner.rb +868 -0
  35. data/lib/graph_weaver/internal/schemas.rb +50 -0
  36. data/lib/graph_weaver/internal/selection.rb +127 -0
  37. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
  38. data/lib/graph_weaver/internal/values.rb +181 -0
  39. data/lib/graph_weaver/internal.rb +206 -0
  40. data/lib/graph_weaver/logging.rb +108 -20
  41. data/lib/graph_weaver/parsing.rb +6 -13
  42. data/lib/graph_weaver/query_module.rb +2 -0
  43. data/lib/graph_weaver/railtie.rb +113 -14
  44. data/lib/graph_weaver/representation.rb +30 -2
  45. data/lib/graph_weaver/response.rb +15 -0
  46. data/lib/graph_weaver/retry.rb +54 -22
  47. data/lib/graph_weaver/rspec.rb +63 -18
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +126 -35
  50. data/lib/graph_weaver/tasks.rb +88 -36
  51. data/lib/graph_weaver/testing/cassette.rb +131 -78
  52. data/lib/graph_weaver/testing/coverage.rb +11 -15
  53. data/lib/graph_weaver/testing/failure.rb +14 -8
  54. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  55. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  56. data/lib/graph_weaver/testing/router.rb +147 -840
  57. data/lib/graph_weaver/testing.rb +40 -83
  58. data/lib/graph_weaver/transport/faraday.rb +1 -1
  59. data/lib/graph_weaver/transport/http.rb +29 -12
  60. data/lib/graph_weaver/transport.rb +11 -34
  61. data/lib/graph_weaver/version.rb +1 -1
  62. data/lib/graph_weaver.rb +221 -118
  63. metadata +17 -13
  64. data/CLAUDE.md +0 -161
  65. data/DECISIONS.md +0 -309
  66. data/Makefile +0 -23
  67. data/NOTES.md +0 -182
  68. data/PLAN.md +0 -115
  69. data/REVIEW.md +0 -946
  70. data/lib/graph_weaver/schemas.rb +0 -46
  71. data/lib/graph_weaver/selection.rb +0 -120
  72. data/lib/graph_weaver/testing/values.rb +0 -98
@@ -5,6 +5,8 @@ require "fileutils"
5
5
  require "graphql"
6
6
  require "json"
7
7
  require_relative "errors"
8
+ require_relative "internal"
9
+ require_relative "schema_diff"
8
10
 
9
11
  # Load a schema for codegen from either format a remote service can hand
10
12
  # you — introspection JSON or SDL, as a file path or the content itself —
@@ -25,7 +27,7 @@ module GraphWeaver::SchemaLoader
25
27
  source = source.to_path if source.respond_to?(:to_path)
26
28
 
27
29
  if source.lstrip.start_with?("{") # introspection JSON content
28
- build_introspection(JSON.parse(source))
30
+ build_introspection(parse_json(source, "the schema content"))
29
31
  elsif sdl_content?(source) # SDL content, one line or many
30
32
  build_sdl(source)
31
33
  elsif source.include?("\n") # content, but nothing we recognize
@@ -40,48 +42,70 @@ module GraphWeaver::SchemaLoader
40
42
  # `type` definition — and a one-line `type Query { hi: String }`, the shape
41
43
  # you'd type in a console, still is.
42
44
  SDL_CONTENT = /\A\s*(?:\#|"|(?:schema|type|interface|union|enum|scalar|directive|input|extend)\b[\s{(@])/
45
+ private_constant :SDL_CONTENT
43
46
 
44
47
  def self.sdl_content?(source)
45
48
  source.match?(SDL_CONTENT)
46
49
  end
47
- private_class_method :sdl_content?
48
50
 
49
51
  def self.load_path(path)
50
52
  case File.extname(path)
51
53
  when ".json"
52
- build_introspection(JSON.parse(read_schema(path)))
54
+ build_introspection(parse_json(read_schema(path), path))
53
55
  when ".graphql", ".gql"
54
56
  build_sdl(read_schema(path))
55
57
  else
56
- raise GraphWeaver::Error,
57
- "unsupported schema format: #{path} — expected a .json (introspection) or " \
58
- ".graphql/.gql (SDL) path, or the content itself#{url_hint(path)}"
58
+ raise GraphWeaver::Error, url_error(path) ||
59
+ "unsupported schema format: #{truncate(path)} — expected a .json (introspection) or " \
60
+ ".graphql/.gql (SDL) path, or the content itself"
59
61
  end
60
62
  end
61
63
  private_class_method :load_path
62
64
 
63
65
  def self.read_schema(path)
64
- File.read(path)
66
+ File.read(GraphWeaver::Internal::Util.resolve(path))
65
67
  rescue SystemCallError => e
66
68
  raise GraphWeaver::Error, "can't read the schema at #{path}: #{e.message}"
67
69
  end
68
70
  private_class_method :read_schema
69
71
 
70
- # A bare host is the near miss worth naming: "unsupported schema format"
71
- # sends you looking at the filesystem when the cause is the missing scheme.
72
- HOST_LIKE = %r{\A[a-z0-9-]+(?:\.[a-z0-9-]+)+(?::\d+)?(?:/\S*)?\z}i
72
+ # A .json that isn't JSON is the corrupt-dump case a truncated download,
73
+ # an interrupted write, a login page saved over it. JSON::ParserError names
74
+ # neither the file nor what it holds, and isn't under the Error umbrella.
75
+ def self.parse_json(text, source)
76
+ JSON.parse(text)
77
+ rescue JSON::ParserError => e
78
+ holds = text.strip.empty? ? "it is empty" : "it starts #{text.lstrip[0, 60].inspect}"
79
+ raise GraphWeaver::Error,
80
+ "#{source} isn't JSON (#{e.message}) — an introspection dump is the whole envelope, " \
81
+ "{\"data\": {\"__schema\": …}}, and #{holds}"
82
+ end
83
+ private_class_method :parse_json
84
+
85
+ # A url with its scheme left off is the near miss worth leading with:
86
+ # "unsupported schema format" sends you looking at the filesystem when what
87
+ # you have is an endpoint. A dotted host, or any host carrying a port —
88
+ # localhost:4000 is the likeliest one to type and has no dot at all.
89
+ HOST_LIKE = %r{\A[a-z0-9-]+(?:(?:\.[a-z0-9-]+)+(?::\d+)?|:\d+)(?:/\S*)?\z}i
73
90
  # dotted-but-not-a-host: a file whose extension we simply don't read
74
91
  FILE_SUFFIXES = %w[yaml yml txt xml sdl md rb erb].freeze
92
+ private_constant :HOST_LIKE, :FILE_SUFFIXES
75
93
 
76
- def self.url_hint(source)
94
+ def self.url_error(source)
77
95
  return unless source.match?(HOST_LIKE)
78
96
 
79
97
  suffix = source[%r{\A[^/:]+}].to_s[/[^.]+\z/].to_s
80
98
  return if FILE_SUFFIXES.include?(suffix.downcase)
81
99
 
82
- %("#{source}" looks like a host; did you mean "https://#{source}"?)
100
+ %("#{source}" looks like a url, not a path — did you mean "https://#{source}"? ) +
101
+ "A schema source is a .json (introspection) or .graphql/.gql (SDL) dump, or the content itself"
83
102
  end
84
- private_class_method :url_hint
103
+ private_class_method :url_error
104
+
105
+ # a source we can't read is quoted back so it's recognizable, not reprinted
106
+ # — an unrecognized 3 MB dump would otherwise BE the error message
107
+ def self.truncate(source) = (source.length > 120) ? "#{source[0, 120]}…" : source
108
+ private_class_method :truncate
85
109
 
86
110
  # Build a schema from SDL, first normalizing whichever federation artifact
87
111
  # it is: a composed supergraph gets its composition machinery stripped (so
@@ -129,6 +153,7 @@ module GraphWeaver::SchemaLoader
129
153
  "specs it declares, a subgraph by applied-but-undeclared @key/@shareable/…",
130
154
  introspection: 'an introspection result — it should be the whole envelope, {"data": {"__schema": …}}',
131
155
  }.freeze
156
+ private_constant :SOURCE_KINDS
132
157
 
133
158
  # graphql-ruby reports a schema it can't build with whatever its internals
134
159
  # happen to raise — NoMethodError, ParseError, a bare RuntimeError — often
@@ -151,6 +176,7 @@ module GraphWeaver::SchemaLoader
151
176
  # @join__ marker misses: one that renamed join (`as: "j"`), and a core
152
177
  # schema that merged nothing but still carries core__Purpose.
153
178
  COMPOSITION_SPEC = %r{@(?:link\s*\(\s*url|core\s*\(\s*feature):\s*"https://specs\.apollo\.dev/(?:join|core)/}
179
+ private_constant :COMPOSITION_SPEC
154
180
 
155
181
  # A composed Fed2 supergraph is marked by @join__* directives (every merged
156
182
  # type carries them); a plain schema has none.
@@ -164,6 +190,7 @@ module GraphWeaver::SchemaLoader
164
190
  # defines everything it applies (and federation_sdl? catches it first).
165
191
  SUBGRAPH_LINK = %r{@link\s*\(\s*url:\s*"https://specs\.apollo\.dev/federation/}
166
192
  SUBGRAPH_MARKERS = %w[key external extends provides requires shareable override interfaceObject].freeze
193
+ private_constant :SUBGRAPH_LINK, :SUBGRAPH_MARKERS
167
194
 
168
195
  # A raw subgraph SDL — `rover subgraph fetch`, `_service { sdl }`, or the
169
196
  # .graphql in a service repo — rather than a composed graph.
@@ -173,6 +200,7 @@ module GraphWeaver::SchemaLoader
173
200
 
174
201
  SUBGRAPH_MARKERS.any? { |name| sdl.match?(/@#{name}\b/) && !sdl.match?(/\bdirective\s+@#{name}\b/) }
175
202
  end
203
+ private_class_method :subgraph_sdl?
176
204
 
177
205
  # The subgraph spec's directives and the types they reference, keyed by
178
206
  # what a definition in the SDL would be named ("@key" for a directive).
@@ -209,6 +237,7 @@ module GraphWeaver::SchemaLoader
209
237
  "link__Import" => "scalar link__Import",
210
238
  "link__Purpose" => "enum link__Purpose { SECURITY EXECUTION }",
211
239
  }.freeze
240
+ private_constant :SUBGRAPH_DIRECTIVE_DEFS, :SUBGRAPH_HELPER_TYPES
212
241
 
213
242
  # A fed-2 subgraph that @links the spec under a namespace — `as: "fed"`,
214
243
  # and "federation" is the default — applies every non-imported directive
@@ -308,6 +337,7 @@ module GraphWeaver::SchemaLoader
308
337
  # only ever adds to this.
309
338
  DEFAULT_PREFIXES = %w[join__ link__ core__].freeze
310
339
  DEFAULT_DIRECTIVES = %w[link core inaccessible].freeze
340
+ private_constant :LINK_DIRECTIVES, :DEFAULT_PREFIXES, :DEFAULT_DIRECTIVES
311
341
 
312
342
  # What THIS document calls federation's machinery — type-name prefixes,
313
343
  # directive names, and the local names @inaccessible answers to — read off
@@ -375,6 +405,7 @@ module GraphWeaver::SchemaLoader
375
405
 
376
406
  VERSION_TAG = /\Av\d+\.\d+\z/
377
407
  GRAPHQL_NAME = /\A[A-Za-z][A-Za-z0-9_]*\z/
408
+ private_constant :VERSION_TAG, :GRAPHQL_NAME
378
409
 
379
410
  # The name a linked spec's elements are namespaced under: the URL's
380
411
  # penultimate path segment when the last is a version tag, else the last one.
@@ -427,6 +458,7 @@ module GraphWeaver::SchemaLoader
427
458
 
428
459
  GraphQL::Language::Nodes::Document.new(definitions: defs).to_query_string
429
460
  end
461
+ private_class_method :strip_federation
430
462
 
431
463
  # a synthetic composition definition to drop: a federation directive
432
464
  # definition (by name), or a synthetic join__*/link__* type (by prefix)
@@ -591,14 +623,14 @@ module GraphWeaver::SchemaLoader
591
623
  # schema.graphql already sits there
592
624
  existing = cache_candidates(cache).find { |candidate| fresh?(candidate, ttl) }
593
625
  if existing
594
- GraphWeaver.log(:info) { "schema cache hit: #{existing}#{" (ttl #{ttl}s)" if ttl}" }
626
+ GraphWeaver::Internal::Log.log(:info) { "schema cache hit: #{existing}#{" (ttl #{ttl}s)" if ttl}" }
595
627
  return load(existing)
596
628
  end
597
629
 
598
- GraphWeaver.log(:info) { "schema cache miss: #{cache}" }
630
+ GraphWeaver::Internal::Log.log(:info) { "schema cache miss: #{cache}" }
599
631
  end
600
632
 
601
- result = GraphWeaver.log_timed(:info, "introspected #{endpoint(transport)}") do
633
+ result = GraphWeaver::Internal::Log.log_timed(:info, "introspected #{endpoint(transport)}") do
602
634
  transport.execute(GraphQL::Introspection.query, variables: {}).to_h
603
635
  end
604
636
  if (errors = result["errors"])
@@ -617,7 +649,6 @@ module GraphWeaver::SchemaLoader
617
649
  schema = GraphQL::Schema.from_introspection(result)
618
650
 
619
651
  if cache
620
- FileUtils.mkdir_p(File.dirname(cache))
621
652
  # the extension picks the format: .json is the verbatim wire
622
653
  # artifact; .graphql/.gql is SDL — human-readable, PR-reviewable
623
654
  # diffs (both generate byte-identical code)
@@ -628,8 +659,17 @@ module GraphWeaver::SchemaLoader
628
659
  header = meta && "# graph_weaver: #{JSON.generate(meta)}\n\n"
629
660
  "#{header}#{schema.to_definition}"
630
661
  end
631
- File.write(cache, content)
632
- GraphWeaver.log(:info) { "wrote schema cache: #{cache} (#{content.bytesize} bytes)" }
662
+ begin
663
+ FileUtils.mkdir_p(File.dirname(cache))
664
+ GraphWeaver::Internal::Util.atomic_write(cache, content)
665
+ rescue SystemCallError => e
666
+ # the introspection worked and the write didn't, which a bare Errno
667
+ # says neither of — and cache: is the argument to change
668
+ raise GraphWeaver::Error,
669
+ "introspected #{endpoint(transport)} but couldn't write the schema cache " \
670
+ "to #{cache}: #{e.message}"
671
+ end
672
+ GraphWeaver::Internal::Log.log(:info) { "wrote schema cache: #{cache} (#{content.bytesize} bytes)" }
633
673
  end
634
674
 
635
675
  schema
@@ -661,13 +701,18 @@ module GraphWeaver::SchemaLoader
661
701
  # The provenance recorded in a dump ({"url" => ..., "introspected_at"
662
702
  # => ..., "auth_env" => ...}), whichever format holds it; nil for
663
703
  # local/unannotated dumps.
704
+ # A corrupt dump records nothing readable, which is what nil says — and
705
+ # `schema:refresh` is the fix for one, so it must not be the thing that
706
+ # trips over it.
664
707
  def self.provenance(path)
665
- content = File.read(path)
708
+ content = File.read(GraphWeaver::Internal::Util.resolve(path))
666
709
  if path.end_with?(".json")
667
710
  JSON.parse(content)["graph_weaver"]
668
711
  elsif (meta = content[/\A# graph_weaver: (\{.*\})$/, 1])
669
712
  JSON.parse(meta)
670
713
  end
714
+ rescue JSON::ParserError
715
+ nil
671
716
  end
672
717
 
673
718
  # The ENV var a dump's token lives in — whichever the generator recorded,
@@ -675,19 +720,20 @@ module GraphWeaver::SchemaLoader
675
720
  # that authenticates and rake tasks that 401.
676
721
  def self.auth_env(path = nil)
677
722
  # the first refresh names a dump that doesn't exist yet
678
- recorded = provenance(path)&.dig("auth_env") if path && File.exist?(path)
723
+ recorded = provenance(path)&.dig("auth_env") if path && File.exist?(GraphWeaver::Internal::Util.resolve(path))
679
724
  recorded || DEFAULT_AUTH_ENV
680
725
  end
726
+ private_class_method :auth_env
681
727
 
682
- # Re-introspect a dump's source and compare — true when the server has
683
- # drifted from what's on disk. transport: overrides the transport (auth
684
- # etc); by default one is built from the dump's recorded url. Wired up
685
- # as `rake graph_weaver:schema:diff` / `:refresh`.
686
- def self.stale?(path, transport: nil)
728
+ # 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`.
732
+ def self.diff(path, transport: nil)
687
733
  transport ||= source_transport(path)
688
734
  fresh = introspect(transport)
689
735
 
690
- fresh.to_definition != load(path).to_definition
736
+ GraphWeaver::SchemaDiff.new(load(path), fresh, source: path, target: endpoint(transport).to_s)
691
737
  end
692
738
 
693
739
  # Re-introspect and rewrite the local dump, returning [path, url].
@@ -718,8 +764,10 @@ module GraphWeaver::SchemaLoader
718
764
  end
719
765
  private_class_method :refresh_hint
720
766
 
721
- # a transport to the dump's recorded url, authenticated from whichever
722
- # ENV var the dump named (else GRAPHWEAVER_AUTH)
767
+ # A transport to the dump's recorded url, authenticated from whichever ENV
768
+ # var the dump named (else DEFAULT_AUTH_ENV). The single way to reach a
769
+ # dump's own server — building one at a call site is how `--auth MY_TOKEN`
770
+ # ends up honoured in some places and not others.
723
771
  def self.source_transport(path)
724
772
  meta = provenance(path)
725
773
  unless meta&.key?("url")
@@ -730,7 +778,6 @@ module GraphWeaver::SchemaLoader
730
778
 
731
779
  GraphWeaver.new(meta["url"], auth: ENV[auth_env(path)]).transport
732
780
  end
733
- private_class_method :source_transport
734
781
 
735
782
  # Where a dump came from, recorded into the file so it can be
736
783
  # re-verified later — a parsable header comment in SDL, a
@@ -750,13 +797,16 @@ module GraphWeaver::SchemaLoader
750
797
  private_class_method :stamp
751
798
 
752
799
  CACHE_EXTENSIONS = %w[.json .graphql .gql].freeze
800
+ private_constant :CACHE_EXTENSIONS
753
801
 
754
802
  # cache: true / :json / :graphql / :gql / a path => the file to write
755
803
  # (nil for no caching). Symbols and true anchor at GraphWeaver.schema_path —
756
804
  # the schema dump the generation workflow reads, so one file serves both
757
805
  # (introspect caches it, rake generate loads it).
758
806
  def self.cache_path(cache)
759
- case cache
807
+ # Rails.root.join(...) hands you a Pathname, as schema: and query: already take
808
+ cache = cache.to_path if cache.respond_to?(:to_path)
809
+ path = case cache
760
810
  when nil, false
761
811
  nil
762
812
  when true
@@ -774,11 +824,13 @@ module GraphWeaver::SchemaLoader
774
824
 
775
825
  cache
776
826
  end
827
+ path && GraphWeaver::Internal::Util.resolve(path)
777
828
  end
778
829
  private_class_method :cache_path
779
830
 
780
831
  # the requested path first, then its siblings in the other formats
781
832
  def self.cache_candidates(path)
833
+ path = GraphWeaver::Internal::Util.resolve(path)
782
834
  base = strip_extension(path)
783
835
  [path, *CACHE_EXTENSIONS.map { |ext| base + ext }].uniq
784
836
  end
@@ -839,8 +891,10 @@ module GraphWeaver::SchemaLoader
839
891
  # One field's routing: `graphs` resolve it, `external` declare it without
840
892
  # resolving it (an @external copy exists so that subgraph can @key or
841
893
  # @requires on it), and requires/provides/override carry the field sets and
842
- # the migration marker verbatim.
843
- Field = Struct.new(:graphs, :external, :requires, :provides, :override)
894
+ # the migration marker verbatim. `contextual` names the arguments a
895
+ # @fromContext fills from an ancestor selection (federation 2.8) a fetch
896
+ # has to supply them, so whoever plans one needs to know they exist.
897
+ Field = Struct.new(:graphs, :external, :requires, :provides, :override, :contextual)
844
898
 
845
899
  # Every @join__ directive this table understands. One it doesn't is a
846
900
  # federation construct nobody has taught it to read, and it lands in
@@ -850,6 +904,7 @@ module GraphWeaver::SchemaLoader
850
904
  join__type join__field join__graph join__implements
851
905
  join__unionMember join__enumValue join__owner
852
906
  ].to_set.freeze
907
+ private_constant :Field, :KNOWN
853
908
 
854
909
  # every subgraph in the graph, in the order the supergraph declares them
855
910
  attr_reader :subgraphs
@@ -938,6 +993,19 @@ module GraphWeaver::SchemaLoader
938
993
  owners(type_name, field_name)
939
994
  end
940
995
 
996
+ # A `subgraphs:` map with string keys, refusing a name this supergraph
997
+ # doesn't have — a typo'd key would otherwise silently configure nothing.
998
+ # Lives here so the test router and the drift check refuse identically:
999
+ # two copies of this drifted apart once already.
1000
+ def named_subgraphs(given)
1001
+ map = (given || {}).to_h { |name, schema| [name.to_s, schema] }
1002
+ unknown = map.keys - subgraphs
1003
+ return map if unknown.empty?
1004
+
1005
+ raise GraphWeaver::ConfigurationError, "subgraphs: names #{unknown.join(", ")}, which " \
1006
+ "this supergraph doesn't have (its subgraphs are #{subgraphs.join(", ")})"
1007
+ end
1008
+
941
1009
  # The @key field sets a subgraph will answer an `_entities` fetch on, each
942
1010
  # as a list of dotted paths ("id organization { id }" => ["id",
943
1011
  # "organization.id"]). A `resolvable: false` key declares a shape this
@@ -994,16 +1062,27 @@ module GraphWeaver::SchemaLoader
994
1062
 
995
1063
  # join__Graph's enum values ARE the subgraphs: ACCOUNTS
996
1064
  # @join__graph(name: "accounts", url: "...").
1065
+ #
1066
+ # This table reads the join spec under its default name, and only that —
1067
+ # a supergraph that renamed it (`@link(url: ".../join/v0.3", as: "j")`)
1068
+ # spells every marker `j__` and lands here with nothing found. Being told
1069
+ # a composed graph has no subgraphs is worse than being refused, so an
1070
+ # empty read is `unsupported` rather than an answer.
997
1071
  def read_graphs
998
1072
  enum = @document.definitions.find do |defn|
999
1073
  defn.is_a?(GraphQL::Language::Nodes::EnumTypeDefinition) && defn.name == "join__Graph"
1000
1074
  end
1001
- return unless enum
1002
1075
 
1003
- enum.values.each do |value|
1076
+ enum&.values&.each do |value|
1004
1077
  name = argument(value.directives.find { |d| d.name == "join__graph" }, "name")
1005
1078
  @names[value.name] = name if name
1006
1079
  end
1080
+
1081
+ return if @names.any?
1082
+
1083
+ @unsupported << "no join__Graph enum names the subgraphs — this schema declares a " \
1084
+ "composition spec, so either it merged nothing, or it renamed the join spec " \
1085
+ "(@link(url: \".../join/v0.3\", as: \"...\")), which this table doesn't follow"
1007
1086
  end
1008
1087
 
1009
1088
  # What each abstract type can be, from the SDL alone — a union's members,
@@ -1087,6 +1166,7 @@ module GraphWeaver::SchemaLoader
1087
1166
  resolvable.filter_map { |d| argument(d, "requires") }.first,
1088
1167
  resolvable.filter_map { |d| argument(d, "provides") }.first,
1089
1168
  applied.filter_map { |d| argument(d, "override") }.first,
1169
+ applied.flat_map { |d| context_arguments(d) },
1090
1170
  )]
1091
1171
  end.to_h
1092
1172
  end
@@ -1102,6 +1182,17 @@ module GraphWeaver::SchemaLoader
1102
1182
  end
1103
1183
  end
1104
1184
 
1185
+ # The argument names a @join__field says come from a @context. The rest of
1186
+ # each entry (the context's name, and the selection read out of it) is the
1187
+ # gateway's business; what a planner needs is that the fetch it builds
1188
+ # would leave these unset.
1189
+ def context_arguments(directive)
1190
+ entries = argument(directive, "contextArguments")
1191
+ return [] unless entries.is_a?(Array)
1192
+
1193
+ entries.filter_map { |entry| argument(entry, "name") }
1194
+ end
1195
+
1105
1196
  # the subgraph NAME a directive's graph: argument points at
1106
1197
  def subgraph(directive) = @names[argument(directive, "graph")]
1107
1198
 
@@ -24,6 +24,36 @@
24
24
  # rake graph_weaver:cassettes:check # fail if a recording no longer casts
25
25
  require_relative "../graph_weaver"
26
26
 
27
+ module GraphWeaver
28
+ module Internal
29
+ # helpers the rake tasks share
30
+ module Tasks
31
+ # The composed supergraph a federation task reads: SUPERGRAPH=, else the
32
+ # conventional dump when that is what it is. Aborts naming the task, so
33
+ # the message says the command to retype.
34
+ def self.supergraph!(task)
35
+ ENV["SUPERGRAPH"] || GraphWeaver::SchemaLoader.locate_path ||
36
+ abort("pass the composed supergraph: rake graph_weaver:federation:#{task} " \
37
+ "SUPERGRAPH=supergraph.graphql")
38
+ end
39
+
40
+ # Registrations the run couldn't match, once each. The logger is the
41
+ # runtime channel and is silent by default; this task's own output is the
42
+ # build channel, and the build is where someone regenerating is looking.
43
+ def self.report_unmatched
44
+ GraphWeaver.unmatched_registrations.each { |message| puts message }
45
+ end
46
+
47
+ # Neither task that needs the committed dump can take one itself, so both
48
+ # say which task can — the same sentence SchemaLoader gives on refresh.
49
+ def self.no_dump
50
+ "no schema dump at #{GraphWeaver.schema_path} — take one: " \
51
+ "rake graph_weaver:schema:refresh URL=https://api.example.com/graphql"
52
+ end
53
+ end
54
+ end
55
+ end
56
+
27
57
  namespace :graph_weaver do
28
58
  # In Rails, boot the app first — initializers register scalars/enums/
29
59
  # helpers and they're baked into generated source. Rails defines
@@ -43,10 +73,24 @@ namespace :graph_weaver do
43
73
  GraphWeaver.skip_generated_load = false
44
74
  end
45
75
 
46
- desc "Generate typed query modules (#{GraphWeaver.queries_paths.first} -> #{GraphWeaver.generated_paths.first})"
76
+ # the default, not GraphWeaver.queries_paths: a desc is baked when this file
77
+ # loads, which in Rails is before :environment has run an initializer that
78
+ # moves it — interpolating would print the default as though it were the setting
79
+ desc "Generate typed query modules (default app/graphql/queries -> app/graphql/generated)"
47
80
  task generate: :environment do
81
+ glob = File.join(GraphWeaver::Internal::Util.resolve(GraphWeaver.generated_paths.first), "**/*.rb")
82
+ before = Dir[glob]
83
+
48
84
  # schema auto-located at GraphWeaver.schema_path, any supported extension
49
- GraphWeaver.generate!.each { |path| puts "wrote #{path}" }
85
+ written = GraphWeaver.generate!
86
+ changed = GraphWeaver.changed_files
87
+ changed.each { |path| puts "wrote #{path}" }
88
+ puts "#{written.size - changed.size} already up to date" if changed.size < written.size
89
+ # generated files are checked in, so a delete this task made is a diff the
90
+ # user is about to find; a run that printed nothing at all had done both
91
+ (before - Dir[glob]).each { |path| puts "pruned #{GraphWeaver::Internal::Util.relative(path)}" }
92
+ puts "no queries in #{GraphWeaver.queries_paths.join(", ")}" if written.empty?
93
+ GraphWeaver::Internal::Tasks.report_unmatched
50
94
  rescue GraphWeaver::Error => e
51
95
  # a typo'd query is a user error — the message names file, position and
52
96
  # fix, and a rake backtrace through codegen only buries it
@@ -57,6 +101,7 @@ namespace :graph_weaver do
57
101
  task verify: :environment do
58
102
  GraphWeaver.verify_generated!
59
103
  puts "generated queries up to date"
104
+ GraphWeaver::Internal::Tasks.report_unmatched
60
105
  rescue GraphWeaver::Error => e
61
106
  abort e.message
62
107
  end
@@ -67,12 +112,18 @@ namespace :graph_weaver do
67
112
 
68
113
  desc "Fail when the server's schema has drifted from the local dump"
69
114
  task diff: :environment do
70
- path = GraphWeaver::SchemaLoader.locate_path or abort "no schema dump at #{GraphWeaver.schema_path}"
71
- if GraphWeaver::SchemaLoader.stale?(path)
72
- abort "#{path} is stale — the server's schema has drifted (rake graph_weaver:schema:refresh)"
115
+ path = GraphWeaver::SchemaLoader.locate_path or abort GraphWeaver::Internal::Tasks.no_dump
116
+ diff = GraphWeaver::SchemaLoader.diff(path)
117
+ dump = GraphWeaver::Internal::Util.relative(path)
118
+ if diff.empty?
119
+ puts "#{dump} matches the server"
120
+ else
121
+ puts diff.report
122
+ # abort writes to unbuffered stderr; the summary above went to
123
+ # block-buffered stdout, so a piped CI log shows it first
124
+ $stdout.flush
125
+ abort "#{dump} is stale — the server's schema has drifted (rake graph_weaver:schema:refresh)"
73
126
  end
74
-
75
- puts "#{path} matches the server"
76
127
  rescue GraphWeaver::Error => e
77
128
  # e.g. a dump with no recorded url — same clean exit as :refresh
78
129
  abort e.message
@@ -80,8 +131,14 @@ namespace :graph_weaver do
80
131
 
81
132
  desc "Re-introspect and rewrite the local dump (URL= to bootstrap the first one)"
82
133
  task refresh: :environment do
134
+ # anything else in URL= reaches introspection as a schema *source*, and
135
+ # fails talking about file extensions rather than the flag just typed
136
+ if ENV["URL"] && !ENV["URL"].match?(GraphWeaver::Client::URL)
137
+ abort "URL= takes an endpoint: rake graph_weaver:schema:refresh URL=https://api.example.com/graphql"
138
+ end
139
+
83
140
  path, url = GraphWeaver::SchemaLoader.refresh!(url: ENV["URL"])
84
- puts "refreshed #{path} from #{url}"
141
+ puts "refreshed #{GraphWeaver::Internal::Util.relative(path)} from #{url}"
85
142
  rescue GraphWeaver::Error => e
86
143
  abort e.message
87
144
  end
@@ -123,12 +180,7 @@ namespace :graph_weaver do
123
180
  task diff: :loaded do
124
181
  require "graph_weaver/federation"
125
182
 
126
- supergraph = ENV["SUPERGRAPH"] || GraphWeaver::SchemaLoader.locate_path
127
- unless supergraph
128
- abort "pass the composed supergraph: rake graph_weaver:federation:diff " \
129
- "SUPERGRAPH=supergraph.graphql"
130
- end
131
-
183
+ supergraph = GraphWeaver::Internal::Tasks.supergraph!("diff")
132
184
  drift = GraphWeaver::Federation::Drift.new(supergraph:)
133
185
  puts drift.report
134
186
 
@@ -152,18 +204,14 @@ namespace :graph_weaver do
152
204
  task subgraphs: :loaded do
153
205
  require "graph_weaver/testing"
154
206
 
155
- supergraph = ENV["SUPERGRAPH"] || GraphWeaver::SchemaLoader.locate_path
156
- unless supergraph
157
- abort "pass the composed supergraph: rake graph_weaver:federation:subgraphs " \
158
- "SUPERGRAPH=supergraph.graphql"
159
- end
207
+ supergraph = GraphWeaver::Internal::Tasks.supergraph!("subgraphs")
160
208
 
161
209
  # Testing::Router derives this map itself; this is for reading what
162
210
  # detection sees when it refuses, and for committing the map instead.
163
211
  table = GraphWeaver::SchemaLoader.routing_table(supergraph)
164
212
  rows = table.subgraphs.map do |name|
165
- found = GraphWeaver::Testing::Subgraphs.candidates(table, name)
166
- sought = GraphWeaver::Testing::Subgraphs.expected(table, name)
213
+ found = GraphWeaver::Internal::Subgraphs.candidates(table, name)
214
+ sought = GraphWeaver::Internal::Subgraphs.expected(table, name)
167
215
  [name, found, sought]
168
216
  end
169
217
  width = rows.map { |name, found, _| %("#{name}" => #{found.first&.name || "nil"},).length }.max
@@ -191,14 +239,8 @@ namespace :graph_weaver do
191
239
  task coverage: :loaded do
192
240
  require "graph_weaver/testing"
193
241
 
194
- supergraph = ENV["SUPERGRAPH"] || GraphWeaver::SchemaLoader.locate_path
195
- unless supergraph
196
- abort "pass the composed supergraph: rake graph_weaver:federation:coverage " \
197
- "SUPERGRAPH=supergraph.graphql"
198
- end
199
-
200
242
  puts GraphWeaver::Testing::Coverage.new(
201
- supergraph:,
243
+ supergraph: GraphWeaver::Internal::Tasks.supergraph!("coverage"),
202
244
  queries: ENV["QUERIES"] || GraphWeaver.queries_paths,
203
245
  ).report
204
246
  rescue GraphWeaver::Error => e
@@ -222,12 +264,15 @@ namespace :graph_weaver do
222
264
  # unlike its siblings this task reads generated modules — they are what
223
265
  # a recording is checked against
224
266
  GraphWeaver.load_generated!
225
- modules = GraphWeaver.query_files.filter_map do |path|
226
- name = GraphWeaver.module_name(path, File.read(path))
267
+ modules = GraphWeaver::Internal::Util.query_files.filter_map do |path|
268
+ name = GraphWeaver::Internal::Util.module_name(path, File.read(path))
227
269
  Object.const_get(name) if Object.const_defined?(name)
228
270
  end
229
271
 
230
- dir = GraphWeaver::Testing.config.cassette_dir
272
+ # Testing.cassette_dir, not config.cassette_dir: the configured path is
273
+ # relative by default and rake runs from wherever it runs from
274
+ dir = GraphWeaver::Testing.cassette_dir
275
+ shown = GraphWeaver::Internal::Util.relative(dir)
231
276
  checks = Dir[File.join(dir, "*.yml")].sort.map do |path|
232
277
  GraphWeaver::Testing::Cassette.new(path).check(modules)
233
278
  end
@@ -244,9 +289,9 @@ namespace :graph_weaver do
244
289
  if checks.sum(&:checked).zero?
245
290
  # a green run that compared nothing is worse than a failure: it would
246
291
  # pass whatever the recordings said (see federation:diff)
247
- abort "this checked nothing, so it proved nothing: no recording in #{dir} carries a query " \
292
+ abort "this checked nothing, so it proved nothing: no recording in #{shown} carries a query " \
248
293
  "any of the #{modules.size} generated modules sends. Drop this task from CI if you " \
249
- "don't record cassettes, or check that #{dir} is where yours live."
294
+ "don't record cassettes, or check that #{shown} is where yours live."
250
295
  end
251
296
 
252
297
  puts "every recording still casts"
@@ -256,11 +301,18 @@ namespace :graph_weaver do
256
301
  task anonymize: :environment do
257
302
  require "graph_weaver/testing"
258
303
 
259
- schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
260
- Dir[File.join(GraphWeaver::Testing.config.cassette_dir, "*.yml")].sort.each do |path|
304
+ # locate, not schema_path: the dump is whichever supported extension is
305
+ # actually on disk, and every sibling task asks the same way
306
+ schema = GraphWeaver::SchemaLoader.locate or abort GraphWeaver::Internal::Tasks.no_dump
307
+ dir = GraphWeaver::Testing.cassette_dir
308
+ paths = Dir[File.join(dir, "*.yml")].sort
309
+ paths.each do |path|
261
310
  GraphWeaver::Testing::Cassette.new(path).anonymize!(schema:)
262
- puts "anonymized #{path}"
311
+ puts "anonymized #{GraphWeaver::Internal::Util.relative(path)}"
263
312
  end
313
+ # silence and exit 0 read as "done" — say where we looked, the way every
314
+ # sibling task does
315
+ puts "no recordings in #{GraphWeaver::Internal::Util.relative(dir)}" if paths.empty?
264
316
  end
265
317
  end
266
318
  end