graph_weaver 0.4.4 → 0.5.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1357 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -136
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -251
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -98
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +617 -264
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +12 -6
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +21 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +15 -1
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +820 -57
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +59 -7
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +186 -62
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +194 -28
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +31 -6
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +74 -18
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +398 -170
  62. metadata +20 -3
@@ -0,0 +1,46 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "graphql"
5
+
6
+ module GraphWeaver
7
+ # The graphql-ruby schema classes already in this process, and what each
8
+ # one defines. Where {SchemaLoader} *builds* a schema from a source — a
9
+ # path, SDL, an introspection dump — this reads classes the app loaded
10
+ # itself.
11
+ #
12
+ # Two features ask exactly these two questions, and match a schema on the
13
+ # coordinates it defines rather than on its class name: {Testing::Subgraphs}
14
+ # (which schema serves which subgraph) and {Federation::Drift} (has a
15
+ # subgraph changed without a recompose). They share the answers so the two
16
+ # can't drift apart.
17
+ module Schemas
18
+ class << self
19
+ # Every named GraphQL::Schema in the process. An anonymous one is
20
+ # graphql-ruby building from SDL — the router's own view of the
21
+ # supergraph is one — and never an app's subgraph.
22
+ def loaded
23
+ descendants(GraphQL::Schema).select(&:name)
24
+ end
25
+
26
+ # Does this schema carry the coordinate — "Type", or "Type.field"?
27
+ def defines?(schema, coordinate)
28
+ type_name, field_name = coordinate.split(".", 2)
29
+ type = schema.get_type(type_name) or return false
30
+ return true unless field_name
31
+
32
+ return type.fields.key?(field_name) if type.respond_to?(:fields)
33
+ # an input object's members are arguments, not fields
34
+ return type.arguments.key?(field_name) if type.respond_to?(:arguments)
35
+
36
+ false
37
+ end
38
+
39
+ private
40
+
41
+ def descendants(klass)
42
+ klass.subclasses.flat_map { |subclass| [subclass] + descendants(subclass) }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -19,7 +19,19 @@ module GraphWeaver
19
19
  .grep(GraphQL::Language::Nodes::FragmentDefinition)
20
20
  .to_h { |fragment| [fragment.name, fragment] }
21
21
 
22
- doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition).first
22
+ operations = doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition)
23
+ # One file, one operation. Requests do carry operationName now, so a
24
+ # second operation would run fine on the wire — what has no answer is
25
+ # naming: a module is named after its FILE (person.graphql =>
26
+ # PersonQuery), and one file can't name two. A convention, not a limit.
27
+ if operations.size > 1
28
+ names = operations.map { |op| op.name ? "'#{op.name}'" : "an anonymous operation" }
29
+ raise GraphWeaver::Error,
30
+ "document defines #{operations.size} operations (#{names.join(", ")}) — " \
31
+ "split them into one file each, since a module is named after its file"
32
+ end
33
+
34
+ operations.first
23
35
  end
24
36
 
25
37
  # The schema type an operation's selections start from.
@@ -32,26 +44,66 @@ module GraphWeaver
32
44
  end
33
45
 
34
46
  # Flatten a selection set as seen by `type`, yielding (result_key,
35
- # field_node) per field: plain fields yield directly; inline fragments
36
- # and named spreads recurse when their type condition applies.
37
- def each_field(type, selections, &block)
47
+ # field_node, conditional) per field: plain fields yield directly; inline
48
+ # fragments and named spreads recurse when their type condition applies.
49
+ # `conditional` is true when any fragment on the way down carried
50
+ # @skip/@include — the whole block may be absent from the response, so
51
+ # everything under it is as optional as a directly-skipped field.
52
+ def each_field(type, selections, visiting = Set.new, conditional: false, &block)
38
53
  selections.each do |selection|
39
54
  case selection
40
55
  when GraphQL::Language::Nodes::Field
41
- yield(selection.alias || selection.name, selection)
56
+ yield(selection.alias || selection.name, selection, conditional)
42
57
  when GraphQL::Language::Nodes::InlineFragment
43
- each_field(type, selection.selections, &block) if applies?(selection.type&.name, type)
58
+ next unless applies?(selection.type&.name, type)
59
+
60
+ each_field(type, selection.selections, visiting,
61
+ conditional: conditional || conditional?(selection), &block)
44
62
  when GraphQL::Language::Nodes::FragmentSpread
45
63
  fragment = @fragments.fetch(selection.name) do
46
64
  raise ArgumentError, "unknown fragment: #{selection.name}"
47
65
  end
48
- each_field(type, fragment.selections, &block) if applies?(fragment.type.name, type)
66
+ if visiting.include?(selection.name)
67
+ raise GraphWeaver::Error, "fragment cycle through #{selection.name}"
68
+ end
69
+
70
+ if applies?(fragment.type.name, type)
71
+ # the directive rides on the SPREAD, not the definition it names
72
+ each_field(type, fragment.selections, visiting | [selection.name],
73
+ conditional: conditional || conditional?(selection), &block)
74
+ end
49
75
  else
50
76
  raise GraphWeaver::Error, "unsupported selection: #{selection.class}"
51
77
  end
52
78
  end
53
79
  end
54
80
 
81
+ # each_field grouped by result key: repeated selections of one field
82
+ # (`a { x } a { y }`, or the same field reached through two fragments)
83
+ # collect together, so callers MERGE their sub-selections rather than
84
+ # last-writer-wins. Codegen relies on this; FakeClient/Anonymizer must too,
85
+ # or they'd fabricate/keep a shape the generated struct can't cast.
86
+ def gather(type, selections)
87
+ gather_conditional(type, selections).transform_values { |occurrences| occurrences.map(&:first) }
88
+ end
89
+
90
+ # gather, keeping each occurrence's [field_node, conditional] — the wire
91
+ # key is guaranteed only when SOME occurrence is unconditional.
92
+ def gather_conditional(type, selections)
93
+ out = {}
94
+ each_field(type, selections) { |key, node, conditional| (out[key] ||= []) << [node, conditional] }
95
+ out
96
+ end
97
+
98
+ # Directives that can drop a selection from the response whatever the
99
+ # schema says — the reason a conditional field's generated type is nilable.
100
+ CONDITIONAL_DIRECTIVES = %w[skip include].freeze
101
+
102
+ # Is this AST node (field, inline fragment, or spread) behind @skip/@include?
103
+ def conditional?(node)
104
+ node.directives.any? { |directive| CONDITIONAL_DIRECTIVES.include?(directive.name) }
105
+ end
106
+
55
107
  # A fragment's type condition applies when it names this type exactly,
56
108
  # or an interface/union this type belongs to (`... on Named { ... }`).
57
109
  def applies?(condition, type)
@@ -5,60 +5,255 @@
5
5
  #
6
6
  # require "graph_weaver/tasks"
7
7
  #
8
- # The tasks use the conventional paths (GraphWeaver.queries_path /
9
- # generated_path / schema_path — override in your Rakefile or an
8
+ # The tasks use the conventional paths (GraphWeaver.queries_paths /
9
+ # generated_paths / schema_path — override in your Rakefile or an
10
10
  # initializer). Register custom scalars before the tasks run — they're
11
11
  # baked into generated source.
12
12
  #
13
- # rake graph_weaver:generate # queries_path -> generated_path
14
- # rake graph_weaver:verify # fail if generated files are stale (CI)
13
+ # Each task names its own subject — generated code, the schema dump, the
14
+ # queries so which question you're asking is the task name:
15
+ #
16
+ # rake graph_weaver:generate # queries_paths -> generated_paths.first
17
+ # rake graph_weaver:verify # fail if generated files are stale (CI)
18
+ # rake graph_weaver:queries:check # fail if a query no longer validates (CI)
19
+ # rake graph_weaver:schema:diff # fail if the server has drifted from the dump
20
+ # rake graph_weaver:schema:refresh # re-introspect and rewrite the dump
21
+ # rake graph_weaver:federation:diff # fail if the supergraph wasn't recomposed
22
+ # rake graph_weaver:federation:coverage # what the local test router can plan
23
+ # rake graph_weaver:federation:subgraphs # which schema serves which subgraph
24
+ # rake graph_weaver:cassettes:check # fail if a recording no longer casts
15
25
  require_relative "../graph_weaver"
16
26
 
17
- # in Rails, run app config first (initializers register scalars/enums/
18
- # helpers, and they're baked into generated source)
19
- GRAPH_WEAVER_DEPS = Rake::Task.task_defined?("environment") ? ["environment"] : []
20
-
21
27
  namespace :graph_weaver do
22
- desc "Generate typed query modules (#{GraphWeaver.queries_path} -> #{GraphWeaver.generated_path})"
23
- task generate: GRAPH_WEAVER_DEPS do
28
+ # In Rails, boot the app first — initializers register scalars/enums/
29
+ # helpers and they're baked into generated source. Rails defines
30
+ # :environment *after* every railtie's rake_tasks block (see
31
+ # Rails::Application#run_tasks_blocks), so whether it exists can only be
32
+ # asked when the task runs, not when this file loads.
33
+ task :environment do
34
+ # These tasks read queries, the schema and the registrations, not a
35
+ # generated module. Saying so lets the railtie skip loading them, so a
36
+ # stale generated file can't block the task that repairs it. It's reset
37
+ # below, so cassettes:check — which does read them — loads them itself.
38
+ GraphWeaver.skip_generated_load = true
39
+ Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
40
+ ensure
41
+ # only meaningful while booting — leaving it set would silently disable
42
+ # loading for anything that boots later in the same process
43
+ GraphWeaver.skip_generated_load = false
44
+ end
45
+
46
+ desc "Generate typed query modules (#{GraphWeaver.queries_paths.first} -> #{GraphWeaver.generated_paths.first})"
47
+ task generate: :environment do
24
48
  # schema auto-located at GraphWeaver.schema_path, any supported extension
25
49
  GraphWeaver.generate!.each { |path| puts "wrote #{path}" }
50
+ rescue GraphWeaver::Error => e
51
+ # a typo'd query is a user error — the message names file, position and
52
+ # fix, and a rake backtrace through codegen only buries it
53
+ abort e.message
26
54
  end
27
55
 
28
56
  desc "Verify generated query modules are up to date"
29
- task verify: GRAPH_WEAVER_DEPS do
57
+ task verify: :environment do
30
58
  GraphWeaver.verify_generated!
31
59
  puts "generated queries up to date"
60
+ rescue GraphWeaver::Error => e
61
+ abort e.message
32
62
  end
33
63
 
34
64
  namespace :schema do
35
- # both tasks re-introspect from the url recorded in the dump
65
+ # both re-introspect from the url recorded in the dump
36
66
  # (GRAPHWEAVER_AUTH supplies a token for private APIs)
37
67
 
38
68
  desc "Fail when the server's schema has drifted from the local dump"
39
- task :verify do
69
+ task diff: :environment do
40
70
  path = GraphWeaver::SchemaLoader.locate_path or abort "no schema dump at #{GraphWeaver.schema_path}"
41
71
  if GraphWeaver::SchemaLoader.stale?(path)
42
72
  abort "#{path} is stale — the server's schema has drifted (rake graph_weaver:schema:refresh)"
43
73
  end
44
74
 
45
75
  puts "#{path} matches the server"
76
+ rescue GraphWeaver::Error => e
77
+ # e.g. a dump with no recorded url — same clean exit as :refresh
78
+ abort e.message
46
79
  end
47
80
 
48
- desc "Re-introspect the recorded url and rewrite the local dump"
49
- task :refresh do
50
- path = GraphWeaver::SchemaLoader.locate_path or abort "no schema dump at #{GraphWeaver.schema_path}"
51
- meta = GraphWeaver::SchemaLoader.provenance(path) or abort "#{path} records no source url"
81
+ desc "Re-introspect and rewrite the local dump (URL= to bootstrap the first one)"
82
+ task refresh: :environment do
83
+ path, url = GraphWeaver::SchemaLoader.refresh!(url: ENV["URL"])
84
+ puts "refreshed #{path} from #{url}"
85
+ rescue GraphWeaver::Error => e
86
+ abort e.message
87
+ end
88
+ end
89
+
90
+ namespace :queries do
91
+ desc "Report checked-in queries that no longer validate against the server's schema"
92
+ task check: :environment do
93
+ failures = GraphWeaver.check_queries
94
+ failures.each do |path, errors|
95
+ puts path
96
+ errors.each do |error|
97
+ position = [error["line"], error["column"]].compact.join(":")
98
+ puts " #{position.empty? ? "" : "#{position} "}#{error["message"]}"
99
+ end
100
+ puts
101
+ end
52
102
 
53
- transport = GraphWeaver.new(meta["url"], auth: ENV["GRAPHWEAVER_AUTH"]).transport
54
- GraphWeaver::SchemaLoader.introspect(transport, cache: path, ttl: 0)
55
- puts "refreshed #{path} from #{meta["url"]}"
103
+ # abort writes to unbuffered stderr; the detail above went to
104
+ # block-buffered stdout, so a piped CI log shows the verdict first
105
+ $stdout.flush
106
+ abort "#{failures.size} invalid #{(failures.size == 1) ? "query" : "queries"}" if failures.any?
107
+ puts "every query validates against the schema"
108
+ end
109
+ end
110
+
111
+ namespace :federation do
112
+ # Every task here answers "which loaded schema serves which subgraph",
113
+ # and Rails leaves config.rake_eager_load false in every environment —
114
+ # so without this each of them reports on zero subgraphs, and :diff
115
+ # exits 0 having compared nothing. Asked when the task runs, not when
116
+ # this file loads: :environment doesn't exist yet at load time.
117
+ task loaded: :environment do
118
+ Rails.application.eager_load! if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
119
+ end
120
+
121
+ # needs no network, so it gates a PR the way verify does
122
+ desc "Fail when a subgraph here changed and the supergraph wasn't recomposed (SUPERGRAPH=)"
123
+ task diff: :loaded do
124
+ require "graph_weaver/federation"
125
+
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
+
132
+ drift = GraphWeaver::Federation::Drift.new(supergraph:)
133
+ puts drift.report
134
+
135
+ # A partly-local supergraph is a supported setup, so a subgraph this
136
+ # process doesn't serve isn't a failure — but comparing against NONE
137
+ # of them is: the gate passes whatever the subgraphs say, which is
138
+ # worse than failing.
139
+ $stdout.flush
140
+ abort "the supergraph is out of date — recompose it and commit the result" if drift.drift?
141
+ if drift.vacuous?
142
+ abort "this checked nothing, so it proved nothing. No schema in this process defines what " \
143
+ "the supergraph says any of its subgraphs resolves — load them (in Rails, that is " \
144
+ "config.eager_load / config.rake_eager_load), or, if they all run elsewhere, drop this " \
145
+ "task from CI: there is nothing here for it to gate."
146
+ end
147
+ rescue GraphWeaver::Error => e
148
+ abort e.message
149
+ end
150
+
151
+ desc "Show which loaded schema serves each subgraph, as a paste-ready map (SUPERGRAPH=)"
152
+ task subgraphs: :loaded do
153
+ require "graph_weaver/testing"
154
+
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
160
+
161
+ # Testing::Router derives this map itself; this is for reading what
162
+ # detection sees when it refuses, and for committing the map instead.
163
+ table = GraphWeaver::SchemaLoader.routing_table(supergraph)
164
+ rows = table.subgraphs.map do |name|
165
+ found = GraphWeaver::Testing::Subgraphs.candidates(table, name)
166
+ sought = GraphWeaver::Testing::Subgraphs.expected(table, name)
167
+ [name, found, sought]
168
+ end
169
+ width = rows.map { |name, found, _| %("#{name}" => #{found.first&.name || "nil"},).length }.max
170
+
171
+ puts "subgraphs: {"
172
+ rows.each do |name, found, sought|
173
+ entry = %( "#{name}" => #{found.one? ? found.first.name : "nil"},).ljust(width + 2)
174
+ # fields first: every schema has a Query, so only the fields say why
175
+ evidence = (sought.grep(/\./) | sought).first(3).join(", ")
176
+ note = if found.one?
177
+ "# matched: defines #{evidence}"
178
+ elsif found.any?
179
+ "# AMBIGUOUS: #{found.map(&:name).sort.join(", ")} all match — pick one"
180
+ else
181
+ "# no loaded schema defines #{evidence} — fill this in"
182
+ end
183
+ puts "#{entry} #{note}"
184
+ end
185
+ puts "}"
186
+ rescue GraphWeaver::Error => e
187
+ abort e.message
188
+ end
189
+
190
+ desc "Report how many queries the local test router can plan (SUPERGRAPH=, QUERIES=)"
191
+ task coverage: :loaded do
192
+ require "graph_weaver/testing"
193
+
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
+ puts GraphWeaver::Testing::Coverage.new(
201
+ supergraph:,
202
+ queries: ENV["QUERIES"] || GraphWeaver.queries_paths,
203
+ ).report
204
+ rescue GraphWeaver::Error => e
205
+ # a supergraph the routing table can't read fully is itself the answer:
206
+ # nothing is plannable, and the message says which construct
207
+ abort e.message
56
208
  end
57
209
  end
58
210
 
59
211
  namespace :cassettes do
212
+ # The drift the other checks structurally can't see. verify, queries:check
213
+ # and schema:diff all ask about the local side; a cassette is the one
214
+ # artifact recorded from someone else's server, and when that server's
215
+ # answers stop fitting the generated structs the failure surfaces mid-spec
216
+ # as a cast error naming a struct and a sorbet frame — nothing points at
217
+ # the stale file.
218
+ desc "Fail when a recorded response no longer casts into the generated structs"
219
+ task check: :environment do
220
+ require "graph_weaver/testing"
221
+
222
+ # unlike its siblings this task reads generated modules — they are what
223
+ # a recording is checked against
224
+ GraphWeaver.load_generated!
225
+ modules = GraphWeaver.query_files.filter_map do |path|
226
+ name = GraphWeaver.module_name(path, File.read(path))
227
+ Object.const_get(name) if Object.const_defined?(name)
228
+ end
229
+
230
+ dir = GraphWeaver::Testing.config.cassette_dir
231
+ checks = Dir[File.join(dir, "*.yml")].sort.map do |path|
232
+ GraphWeaver::Testing::Cassette.new(path).check(modules)
233
+ end
234
+ checks.each { |check| puts check.report }
235
+
236
+ stale = checks.sum { |check| check.stale.size }
237
+ $stdout.flush
238
+ if stale.positive?
239
+ abort "#{stale} stale #{(stale == 1) ? "recording" : "recordings"} — the recorded server's " \
240
+ "answers no longer fit the structs generated from your schema. Re-record " \
241
+ "(GRAPHWEAVER_RECORD=1, with a live client:), or regenerate if it was the schema dump " \
242
+ "that moved: rake graph_weaver:generate."
243
+ end
244
+ if checks.sum(&:checked).zero?
245
+ # a green run that compared nothing is worse than a failure: it would
246
+ # pass whatever the recordings said (see federation:diff)
247
+ abort "this checked nothing, so it proved nothing: no recording in #{dir} carries a query " \
248
+ "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."
250
+ end
251
+
252
+ puts "every recording still casts"
253
+ end
254
+
60
255
  desc "Anonymize every cassette in Testing.config.cassette_dir (PII-safe to commit)"
61
- task :anonymize do
256
+ task anonymize: :environment do
62
257
  require "graph_weaver/testing"
63
258
 
64
259
  schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)