graph_weaver 0.5.1 → 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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +409 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +6 -1
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +73 -16
  8. data/docs/federation.md +201 -151
  9. data/docs/generated_modules.md +222 -165
  10. data/docs/getting_started.md +105 -81
  11. data/docs/logging.md +34 -4
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +191 -151
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +210 -11
  16. data/lib/generators/graph_weaver/install_generator.rb +16 -1
  17. data/lib/graph_weaver/client.rb +46 -13
  18. data/lib/graph_weaver/codegen/aliases.rb +5 -4
  19. data/lib/graph_weaver/codegen/emit.rb +96 -39
  20. data/lib/graph_weaver/codegen/enum_type.rb +3 -0
  21. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  22. data/lib/graph_weaver/codegen/scalar_type.rb +82 -79
  23. data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
  24. data/lib/graph_weaver/codegen.rb +284 -84
  25. data/lib/graph_weaver/coerce.rb +113 -0
  26. data/lib/graph_weaver/errors.rb +30 -7
  27. data/lib/graph_weaver/federation.rb +6 -5
  28. data/lib/graph_weaver/hints.rb +76 -2
  29. data/lib/graph_weaver/in_process.rb +11 -8
  30. data/lib/graph_weaver/inflect.rb +2 -0
  31. data/lib/graph_weaver/input_struct.rb +115 -12
  32. data/lib/graph_weaver/internal/overrides.rb +101 -0
  33. data/lib/graph_weaver/internal/planner.rb +868 -0
  34. data/lib/graph_weaver/internal/schemas.rb +50 -0
  35. data/lib/graph_weaver/internal/selection.rb +127 -0
  36. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
  37. data/lib/graph_weaver/internal/values.rb +181 -0
  38. data/lib/graph_weaver/internal.rb +206 -0
  39. data/lib/graph_weaver/logging.rb +108 -20
  40. data/lib/graph_weaver/parsing.rb +5 -4
  41. data/lib/graph_weaver/query_module.rb +2 -0
  42. data/lib/graph_weaver/railtie.rb +113 -14
  43. data/lib/graph_weaver/representation.rb +30 -2
  44. data/lib/graph_weaver/response.rb +15 -0
  45. data/lib/graph_weaver/retry.rb +54 -22
  46. data/lib/graph_weaver/rspec.rb +50 -11
  47. data/lib/graph_weaver/schema_diff.rb +293 -0
  48. data/lib/graph_weaver/schema_loader.rb +96 -29
  49. data/lib/graph_weaver/tasks.rb +78 -29
  50. data/lib/graph_weaver/testing/cassette.rb +49 -65
  51. data/lib/graph_weaver/testing/coverage.rb +5 -4
  52. data/lib/graph_weaver/testing/failure.rb +10 -6
  53. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  54. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  55. data/lib/graph_weaver/testing/router.rb +94 -808
  56. data/lib/graph_weaver/testing.rb +35 -84
  57. data/lib/graph_weaver/transport/faraday.rb +1 -1
  58. data/lib/graph_weaver/transport/http.rb +29 -12
  59. data/lib/graph_weaver/transport.rb +11 -34
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +188 -110
  62. metadata +10 -5
  63. data/lib/graph_weaver/schemas.rb +0 -48
  64. data/lib/graph_weaver/selection.rb +0 -120
  65. 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,6 +42,7 @@ 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)
@@ -48,39 +51,61 @@ module GraphWeaver::SchemaLoader
48
51
  def self.load_path(path)
49
52
  case File.extname(path)
50
53
  when ".json"
51
- build_introspection(JSON.parse(read_schema(path)))
54
+ build_introspection(parse_json(read_schema(path), path))
52
55
  when ".graphql", ".gql"
53
56
  build_sdl(read_schema(path))
54
57
  else
55
- raise GraphWeaver::Error,
56
- "unsupported schema format: #{path} — expected a .json (introspection) or " \
57
- ".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"
58
61
  end
59
62
  end
60
63
  private_class_method :load_path
61
64
 
62
65
  def self.read_schema(path)
63
- File.read(path)
66
+ File.read(GraphWeaver::Internal::Util.resolve(path))
64
67
  rescue SystemCallError => e
65
68
  raise GraphWeaver::Error, "can't read the schema at #{path}: #{e.message}"
66
69
  end
67
70
  private_class_method :read_schema
68
71
 
69
- # A bare host is the near miss worth naming: "unsupported schema format"
70
- # sends you looking at the filesystem when the cause is the missing scheme.
71
- 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
72
90
  # dotted-but-not-a-host: a file whose extension we simply don't read
73
91
  FILE_SUFFIXES = %w[yaml yml txt xml sdl md rb erb].freeze
92
+ private_constant :HOST_LIKE, :FILE_SUFFIXES
74
93
 
75
- def self.url_hint(source)
94
+ def self.url_error(source)
76
95
  return unless source.match?(HOST_LIKE)
77
96
 
78
97
  suffix = source[%r{\A[^/:]+}].to_s[/[^.]+\z/].to_s
79
98
  return if FILE_SUFFIXES.include?(suffix.downcase)
80
99
 
81
- %("#{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"
82
102
  end
83
- 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
84
109
 
85
110
  # Build a schema from SDL, first normalizing whichever federation artifact
86
111
  # it is: a composed supergraph gets its composition machinery stripped (so
@@ -128,6 +153,7 @@ module GraphWeaver::SchemaLoader
128
153
  "specs it declares, a subgraph by applied-but-undeclared @key/@shareable/…",
129
154
  introspection: 'an introspection result — it should be the whole envelope, {"data": {"__schema": …}}',
130
155
  }.freeze
156
+ private_constant :SOURCE_KINDS
131
157
 
132
158
  # graphql-ruby reports a schema it can't build with whatever its internals
133
159
  # happen to raise — NoMethodError, ParseError, a bare RuntimeError — often
@@ -150,6 +176,7 @@ module GraphWeaver::SchemaLoader
150
176
  # @join__ marker misses: one that renamed join (`as: "j"`), and a core
151
177
  # schema that merged nothing but still carries core__Purpose.
152
178
  COMPOSITION_SPEC = %r{@(?:link\s*\(\s*url|core\s*\(\s*feature):\s*"https://specs\.apollo\.dev/(?:join|core)/}
179
+ private_constant :COMPOSITION_SPEC
153
180
 
154
181
  # A composed Fed2 supergraph is marked by @join__* directives (every merged
155
182
  # type carries them); a plain schema has none.
@@ -163,6 +190,7 @@ module GraphWeaver::SchemaLoader
163
190
  # defines everything it applies (and federation_sdl? catches it first).
164
191
  SUBGRAPH_LINK = %r{@link\s*\(\s*url:\s*"https://specs\.apollo\.dev/federation/}
165
192
  SUBGRAPH_MARKERS = %w[key external extends provides requires shareable override interfaceObject].freeze
193
+ private_constant :SUBGRAPH_LINK, :SUBGRAPH_MARKERS
166
194
 
167
195
  # A raw subgraph SDL — `rover subgraph fetch`, `_service { sdl }`, or the
168
196
  # .graphql in a service repo — rather than a composed graph.
@@ -172,6 +200,7 @@ module GraphWeaver::SchemaLoader
172
200
 
173
201
  SUBGRAPH_MARKERS.any? { |name| sdl.match?(/@#{name}\b/) && !sdl.match?(/\bdirective\s+@#{name}\b/) }
174
202
  end
203
+ private_class_method :subgraph_sdl?
175
204
 
176
205
  # The subgraph spec's directives and the types they reference, keyed by
177
206
  # what a definition in the SDL would be named ("@key" for a directive).
@@ -208,6 +237,7 @@ module GraphWeaver::SchemaLoader
208
237
  "link__Import" => "scalar link__Import",
209
238
  "link__Purpose" => "enum link__Purpose { SECURITY EXECUTION }",
210
239
  }.freeze
240
+ private_constant :SUBGRAPH_DIRECTIVE_DEFS, :SUBGRAPH_HELPER_TYPES
211
241
 
212
242
  # A fed-2 subgraph that @links the spec under a namespace — `as: "fed"`,
213
243
  # and "federation" is the default — applies every non-imported directive
@@ -307,6 +337,7 @@ module GraphWeaver::SchemaLoader
307
337
  # only ever adds to this.
308
338
  DEFAULT_PREFIXES = %w[join__ link__ core__].freeze
309
339
  DEFAULT_DIRECTIVES = %w[link core inaccessible].freeze
340
+ private_constant :LINK_DIRECTIVES, :DEFAULT_PREFIXES, :DEFAULT_DIRECTIVES
310
341
 
311
342
  # What THIS document calls federation's machinery — type-name prefixes,
312
343
  # directive names, and the local names @inaccessible answers to — read off
@@ -374,6 +405,7 @@ module GraphWeaver::SchemaLoader
374
405
 
375
406
  VERSION_TAG = /\Av\d+\.\d+\z/
376
407
  GRAPHQL_NAME = /\A[A-Za-z][A-Za-z0-9_]*\z/
408
+ private_constant :VERSION_TAG, :GRAPHQL_NAME
377
409
 
378
410
  # The name a linked spec's elements are namespaced under: the URL's
379
411
  # penultimate path segment when the last is a version tag, else the last one.
@@ -426,6 +458,7 @@ module GraphWeaver::SchemaLoader
426
458
 
427
459
  GraphQL::Language::Nodes::Document.new(definitions: defs).to_query_string
428
460
  end
461
+ private_class_method :strip_federation
429
462
 
430
463
  # a synthetic composition definition to drop: a federation directive
431
464
  # definition (by name), or a synthetic join__*/link__* type (by prefix)
@@ -590,14 +623,14 @@ module GraphWeaver::SchemaLoader
590
623
  # schema.graphql already sits there
591
624
  existing = cache_candidates(cache).find { |candidate| fresh?(candidate, ttl) }
592
625
  if existing
593
- 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}" }
594
627
  return load(existing)
595
628
  end
596
629
 
597
- GraphWeaver.log(:info) { "schema cache miss: #{cache}" }
630
+ GraphWeaver::Internal::Log.log(:info) { "schema cache miss: #{cache}" }
598
631
  end
599
632
 
600
- result = GraphWeaver.log_timed(:info, "introspected #{endpoint(transport)}") do
633
+ result = GraphWeaver::Internal::Log.log_timed(:info, "introspected #{endpoint(transport)}") do
601
634
  transport.execute(GraphQL::Introspection.query, variables: {}).to_h
602
635
  end
603
636
  if (errors = result["errors"])
@@ -616,7 +649,6 @@ module GraphWeaver::SchemaLoader
616
649
  schema = GraphQL::Schema.from_introspection(result)
617
650
 
618
651
  if cache
619
- FileUtils.mkdir_p(File.dirname(cache))
620
652
  # the extension picks the format: .json is the verbatim wire
621
653
  # artifact; .graphql/.gql is SDL — human-readable, PR-reviewable
622
654
  # diffs (both generate byte-identical code)
@@ -627,8 +659,17 @@ module GraphWeaver::SchemaLoader
627
659
  header = meta && "# graph_weaver: #{JSON.generate(meta)}\n\n"
628
660
  "#{header}#{schema.to_definition}"
629
661
  end
630
- File.write(cache, content)
631
- 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)" }
632
673
  end
633
674
 
634
675
  schema
@@ -660,13 +701,18 @@ module GraphWeaver::SchemaLoader
660
701
  # The provenance recorded in a dump ({"url" => ..., "introspected_at"
661
702
  # => ..., "auth_env" => ...}), whichever format holds it; nil for
662
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.
663
707
  def self.provenance(path)
664
- content = File.read(path)
708
+ content = File.read(GraphWeaver::Internal::Util.resolve(path))
665
709
  if path.end_with?(".json")
666
710
  JSON.parse(content)["graph_weaver"]
667
711
  elsif (meta = content[/\A# graph_weaver: (\{.*\})$/, 1])
668
712
  JSON.parse(meta)
669
713
  end
714
+ rescue JSON::ParserError
715
+ nil
670
716
  end
671
717
 
672
718
  # The ENV var a dump's token lives in — whichever the generator recorded,
@@ -674,19 +720,20 @@ module GraphWeaver::SchemaLoader
674
720
  # that authenticates and rake tasks that 401.
675
721
  def self.auth_env(path = nil)
676
722
  # the first refresh names a dump that doesn't exist yet
677
- 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))
678
724
  recorded || DEFAULT_AUTH_ENV
679
725
  end
726
+ private_class_method :auth_env
680
727
 
681
- # Re-introspect a dump's source and compare — true when the server has
682
- # drifted from what's on disk. transport: overrides the transport (auth
683
- # etc); by default one is built from the dump's recorded url. Wired up
684
- # as `rake graph_weaver:schema:diff` / `:refresh`.
685
- 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)
686
733
  transport ||= source_transport(path)
687
734
  fresh = introspect(transport)
688
735
 
689
- fresh.to_definition != load(path).to_definition
736
+ GraphWeaver::SchemaDiff.new(load(path), fresh, source: path, target: endpoint(transport).to_s)
690
737
  end
691
738
 
692
739
  # Re-introspect and rewrite the local dump, returning [path, url].
@@ -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
@@ -1111,6 +1166,7 @@ module GraphWeaver::SchemaLoader
1111
1166
  resolvable.filter_map { |d| argument(d, "requires") }.first,
1112
1167
  resolvable.filter_map { |d| argument(d, "provides") }.first,
1113
1168
  applied.filter_map { |d| argument(d, "override") }.first,
1169
+ applied.flat_map { |d| context_arguments(d) },
1114
1170
  )]
1115
1171
  end.to_h
1116
1172
  end
@@ -1126,6 +1182,17 @@ module GraphWeaver::SchemaLoader
1126
1182
  end
1127
1183
  end
1128
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
+
1129
1196
  # the subgraph NAME a directive's graph: argument points at
1130
1197
  def subgraph(directive) = @names[argument(directive, "graph")]
1131
1198
 
@@ -25,15 +25,31 @@
25
25
  require_relative "../graph_weaver"
26
26
 
27
27
  module GraphWeaver
28
- # helpers the rake tasks share; not part of the library's API
29
- module Tasks
30
- # The composed supergraph a federation task reads: SUPERGRAPH=, else the
31
- # conventional dump when that is what it is. Aborts naming the task, so
32
- # the message says the command to retype.
33
- def self.supergraph!(task)
34
- ENV["SUPERGRAPH"] || GraphWeaver::SchemaLoader.locate_path ||
35
- abort("pass the composed supergraph: rake graph_weaver:federation:#{task} " \
36
- "SUPERGRAPH=supergraph.graphql")
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
37
53
  end
38
54
  end
39
55
  end
@@ -57,10 +73,24 @@ namespace :graph_weaver do
57
73
  GraphWeaver.skip_generated_load = false
58
74
  end
59
75
 
60
- 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)"
61
80
  task generate: :environment do
81
+ glob = File.join(GraphWeaver::Internal::Util.resolve(GraphWeaver.generated_paths.first), "**/*.rb")
82
+ before = Dir[glob]
83
+
62
84
  # schema auto-located at GraphWeaver.schema_path, any supported extension
63
- 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
64
94
  rescue GraphWeaver::Error => e
65
95
  # a typo'd query is a user error — the message names file, position and
66
96
  # fix, and a rake backtrace through codegen only buries it
@@ -71,6 +101,7 @@ namespace :graph_weaver do
71
101
  task verify: :environment do
72
102
  GraphWeaver.verify_generated!
73
103
  puts "generated queries up to date"
104
+ GraphWeaver::Internal::Tasks.report_unmatched
74
105
  rescue GraphWeaver::Error => e
75
106
  abort e.message
76
107
  end
@@ -81,12 +112,18 @@ namespace :graph_weaver do
81
112
 
82
113
  desc "Fail when the server's schema has drifted from the local dump"
83
114
  task diff: :environment do
84
- path = GraphWeaver::SchemaLoader.locate_path or abort "no schema dump at #{GraphWeaver.schema_path}"
85
- if GraphWeaver::SchemaLoader.stale?(path)
86
- 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)"
87
126
  end
88
-
89
- puts "#{path} matches the server"
90
127
  rescue GraphWeaver::Error => e
91
128
  # e.g. a dump with no recorded url — same clean exit as :refresh
92
129
  abort e.message
@@ -94,8 +131,14 @@ namespace :graph_weaver do
94
131
 
95
132
  desc "Re-introspect and rewrite the local dump (URL= to bootstrap the first one)"
96
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
+
97
140
  path, url = GraphWeaver::SchemaLoader.refresh!(url: ENV["URL"])
98
- puts "refreshed #{path} from #{url}"
141
+ puts "refreshed #{GraphWeaver::Internal::Util.relative(path)} from #{url}"
99
142
  rescue GraphWeaver::Error => e
100
143
  abort e.message
101
144
  end
@@ -137,7 +180,7 @@ namespace :graph_weaver do
137
180
  task diff: :loaded do
138
181
  require "graph_weaver/federation"
139
182
 
140
- supergraph = GraphWeaver::Tasks.supergraph!("diff")
183
+ supergraph = GraphWeaver::Internal::Tasks.supergraph!("diff")
141
184
  drift = GraphWeaver::Federation::Drift.new(supergraph:)
142
185
  puts drift.report
143
186
 
@@ -161,14 +204,14 @@ namespace :graph_weaver do
161
204
  task subgraphs: :loaded do
162
205
  require "graph_weaver/testing"
163
206
 
164
- supergraph = GraphWeaver::Tasks.supergraph!("subgraphs")
207
+ supergraph = GraphWeaver::Internal::Tasks.supergraph!("subgraphs")
165
208
 
166
209
  # Testing::Router derives this map itself; this is for reading what
167
210
  # detection sees when it refuses, and for committing the map instead.
168
211
  table = GraphWeaver::SchemaLoader.routing_table(supergraph)
169
212
  rows = table.subgraphs.map do |name|
170
- found = GraphWeaver::Testing::Subgraphs.candidates(table, name)
171
- sought = GraphWeaver::Testing::Subgraphs.expected(table, name)
213
+ found = GraphWeaver::Internal::Subgraphs.candidates(table, name)
214
+ sought = GraphWeaver::Internal::Subgraphs.expected(table, name)
172
215
  [name, found, sought]
173
216
  end
174
217
  width = rows.map { |name, found, _| %("#{name}" => #{found.first&.name || "nil"},).length }.max
@@ -197,7 +240,7 @@ namespace :graph_weaver do
197
240
  require "graph_weaver/testing"
198
241
 
199
242
  puts GraphWeaver::Testing::Coverage.new(
200
- supergraph: GraphWeaver::Tasks.supergraph!("coverage"),
243
+ supergraph: GraphWeaver::Internal::Tasks.supergraph!("coverage"),
201
244
  queries: ENV["QUERIES"] || GraphWeaver.queries_paths,
202
245
  ).report
203
246
  rescue GraphWeaver::Error => e
@@ -221,14 +264,15 @@ namespace :graph_weaver do
221
264
  # unlike its siblings this task reads generated modules — they are what
222
265
  # a recording is checked against
223
266
  GraphWeaver.load_generated!
224
- modules = GraphWeaver.query_files.filter_map do |path|
225
- 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))
226
269
  Object.const_get(name) if Object.const_defined?(name)
227
270
  end
228
271
 
229
272
  # Testing.cassette_dir, not config.cassette_dir: the configured path is
230
273
  # relative by default and rake runs from wherever it runs from
231
274
  dir = GraphWeaver::Testing.cassette_dir
275
+ shown = GraphWeaver::Internal::Util.relative(dir)
232
276
  checks = Dir[File.join(dir, "*.yml")].sort.map do |path|
233
277
  GraphWeaver::Testing::Cassette.new(path).check(modules)
234
278
  end
@@ -245,9 +289,9 @@ namespace :graph_weaver do
245
289
  if checks.sum(&:checked).zero?
246
290
  # a green run that compared nothing is worse than a failure: it would
247
291
  # pass whatever the recordings said (see federation:diff)
248
- 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 " \
249
293
  "any of the #{modules.size} generated modules sends. Drop this task from CI if you " \
250
- "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."
251
295
  end
252
296
 
253
297
  puts "every recording still casts"
@@ -259,11 +303,16 @@ namespace :graph_weaver do
259
303
 
260
304
  # locate, not schema_path: the dump is whichever supported extension is
261
305
  # actually on disk, and every sibling task asks the same way
262
- schema = GraphWeaver::SchemaLoader.locate or abort "no schema dump at #{GraphWeaver.schema_path}"
263
- Dir[File.join(GraphWeaver::Testing.cassette_dir, "*.yml")].sort.each do |path|
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|
264
310
  GraphWeaver::Testing::Cassette.new(path).anonymize!(schema:)
265
- puts "anonymized #{path}"
311
+ puts "anonymized #{GraphWeaver::Internal::Util.relative(path)}"
266
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?
267
316
  end
268
317
  end
269
318
  end