graph_weaver 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1447 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +20 -6
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +497 -104
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +28 -0
  15. data/docs/scalars.md +190 -26
  16. data/docs/testing.md +457 -58
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +328 -3
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +404 -197
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +40 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +474 -106
  70. metadata +56 -1
@@ -20,16 +20,20 @@ module GraphWeaver
20
20
  # where {SchemaLoader.diff} needs the server and answers a different
21
21
  # question (has the *server* drifted from my dump).
22
22
  #
23
- # Both directions, because they mean opposite things:
23
+ # Three kinds, because they mean different things:
24
24
  #
25
25
  # - **stale** — the supergraph carries `Product.weight` and no schema
26
26
  # here defines it any more. Recompose.
27
+ # - **shape** — both carry `Product.weight`, with different types
28
+ # (`Int!` composed, `Float` here). Recompose — but nothing was added or
29
+ # dropped, so every coordinate matches and only the signatures tell.
27
30
  # - **not composed in** — a schema here defines `Product.dimensions` and
28
31
  # the supergraph doesn't carry it. Publish the subgraph.
29
32
  #
30
33
  # What "defines" means: a coordinate is compared only against the
31
34
  # schemas that could *be* the subgraph the supergraph attributes it to —
32
- # the ones defining every non-root type it declares. Exact field-set
35
+ # the ones defining every non-root type that subgraph declares **and**
36
+ # something the supergraph attributes to it alone. Exact field-set
33
37
  # equality would be too strict in both directions: a subgraph carries
34
38
  # federation plumbing (`_entities`, `_service`) the supergraph never
35
39
  # has, and a field can legitimately sit in more than one subgraph
@@ -46,8 +50,7 @@ module GraphWeaver
46
50
  # misleading on the graphs this is for.
47
51
  class Drift
48
52
  # Composition names the root types conventionally, and every subgraph
49
- # declares one — so a root can't tell subgraphs apart, and a schema
50
- # is recognized by the other types it defines.
53
+ # declares one — so a root can't tell subgraphs apart (see #identifying).
51
54
  ROOTS = %w[Query Mutation Subscription].freeze
52
55
  private_constant :ROOTS
53
56
 
@@ -55,12 +58,20 @@ module GraphWeaver
55
58
  # subgraphs resolve it, and no schema of theirs here defines it
56
59
  attr_reader :stale
57
60
 
61
+ # { "Product.weight" => { "subgraphs" => ["products"], "supergraph" =>
62
+ # "Int!", "here" => ["Float"] } } — both carry the field, with
63
+ # different types. Its own kind because the fix is the same recompose
64
+ # and the cause is not: nothing was added or dropped, so the
65
+ # coordinates match and only the signatures say the composition is
66
+ # describing a graph nobody serves.
67
+ attr_reader :shape
68
+
58
69
  # { "Product.dimensions" => ["Products::Schema"] } — defined here,
59
70
  # absent from the supergraph
60
71
  attr_reader :uncomposed
61
72
 
62
- # { "inventory" => ["Warehouse"] } — subgraph => the types that would
63
- # identify it, which nothing here defines
73
+ # { "inventory" => ["Warehouse", "Warehouse.bays"] } — subgraph => the
74
+ # coordinates that would identify it, which nothing here defines
64
75
  attr_reader :skipped
65
76
 
66
77
  # subgraphs answered with fabricated data, so there's no real schema
@@ -70,6 +81,18 @@ module GraphWeaver
70
81
  # every subgraph that was actually compared
71
82
  attr_reader :checked
72
83
 
84
+ # every subgraph this supergraph names, compared or not
85
+ def subgraphs = @table.subgraphs
86
+
87
+ # The federated schemas in this process that no subgraph of this
88
+ # supergraph is. `stale` looks from the supergraph's side — every task
89
+ # here walks its subgraph list — and this looks from the code's, which
90
+ # is the only side a subgraph dropped from the composition is still on.
91
+ # Not `drift?`: a process that loads a subgraph of a supergraph nobody
92
+ # here reads looks exactly the same, and nothing on either side tells
93
+ # them apart.
94
+ def unplaced = @schemas.select { |schema| subgraph?(schema) } - @compared
95
+
73
96
  # supergraph: the composed SDL (a path or the content); defaults to
74
97
  # the conventional dump. subgraphs: the same map {Testing::Router}
75
98
  # takes — a named schema skips detection, `:fake` (like anything else
@@ -85,15 +108,17 @@ module GraphWeaver
85
108
  @given = @table.named_subgraphs(subgraphs)
86
109
  @schemas = schemas || GraphWeaver::Internal::Schemas.loaded
87
110
  @stale = {}
111
+ @shape = {}
88
112
  @uncomposed = {}
89
113
  @skipped = {}
90
114
  @faked = []
91
115
  @checked = []
116
+ @compared = []
92
117
  compare
93
118
  end
94
119
 
95
120
  # whether the supergraph and the code here disagree — what CI gates on
96
- def drift? = @stale.any? || @uncomposed.any?
121
+ def drift? = @stale.any? || @shape.any? || @uncomposed.any?
97
122
 
98
123
  # Nothing was compared, so "no drift" is vacuous: the gate would pass
99
124
  # whatever the subgraphs said. Categorically different from "checked 3
@@ -107,6 +132,7 @@ module GraphWeaver
107
132
  def to_h
108
133
  {
109
134
  "stale" => @stale,
135
+ "shape" => @shape,
110
136
  "uncomposed" => @uncomposed,
111
137
  "skipped" => @skipped,
112
138
  "faked" => @faked,
@@ -116,19 +142,20 @@ module GraphWeaver
116
142
  def report
117
143
  return "#{@source} names no subgraphs" if @table.subgraphs.empty?
118
144
 
119
- [headline, *section(STALE, @stale), *section(UNCOMPOSED, @uncomposed),
145
+ [headline, *section(STALE, @stale), *shape_section, *section(UNCOMPOSED, @uncomposed),
120
146
  *skipped_section, *faked_section].join("\n")
121
147
  end
122
148
  alias to_s report
123
149
 
124
150
  def inspect
125
- "#<#{self.class.name} #{@stale.size} stale, #{@uncomposed.size} uncomposed, " \
126
- "#{@checked.size}/#{@table.subgraphs.size} checked>"
151
+ "#<#{self.class.name} #{@stale.size} stale, #{@shape.size} shape, " \
152
+ "#{@uncomposed.size} uncomposed, #{@checked.size}/#{@table.subgraphs.size} checked>"
127
153
  end
128
154
 
129
155
  private
130
156
 
131
157
  STALE = "stale — the supergraph carries these, no schema here defines them (recompose):"
158
+ SHAPE = "shape — both carry these, with different types (recompose):"
132
159
  UNCOMPOSED = "not composed in — a schema here defines these, the supergraph doesn't carry them:"
133
160
 
134
161
  def compare
@@ -136,15 +163,24 @@ module GraphWeaver
136
163
  next unless (fitting = comparable(name))
137
164
 
138
165
  @checked << name
166
+ @compared |= fitting
139
167
  record_stale(name, fitting)
140
168
  record_uncomposed(name, fitting)
141
169
  end
142
170
  end
143
171
 
172
+ # A federation subgraph serves Query._service — that is how a gateway
173
+ # reads one to compose it, so every implementation carries it and an
174
+ # app's own API schema doesn't.
175
+ def subgraph?(schema) = !!schema.query&.fields&.key?("_service")
176
+
144
177
  # The schemas to compare this subgraph against, or nil when there are
145
- # none — recording why. A named schema is taken as given; otherwise
146
- # the schemas defining every type the supergraph says it declares are
147
- # the ones that could be it.
178
+ # none — recording why. A named schema is taken as given; otherwise a
179
+ # schema could be this subgraph when it defines every non-root type the
180
+ # supergraph says it declares AND at least one coordinate the supergraph
181
+ # attributes to it alone: the types say it is the same shape, the
182
+ # exclusive coordinate says it is THIS subgraph and not the neighbour it
183
+ # shares an entity with.
148
184
  def comparable(name)
149
185
  if @given.key?(name)
150
186
  schema = @given[name]
@@ -156,8 +192,12 @@ module GraphWeaver
156
192
  return
157
193
  end
158
194
 
159
- anchors = identifying_types(name)
160
- fitting = anchors.empty? ? [] : @schemas.select { |s| anchors.all? { |t| s.get_type(t) } }
195
+ shape = identifying_types(name)
196
+ anchors = identifying(name)
197
+ fitting = @schemas.select do |schema|
198
+ shape.all? { |type| schema.get_type(type) } &&
199
+ anchors.any? { |coordinate| GraphWeaver::Internal::Schemas.defines?(schema, coordinate) }
200
+ end
161
201
  return fitting if fitting.any?
162
202
 
163
203
  @skipped[name] = anchors
@@ -168,23 +208,52 @@ module GraphWeaver
168
208
  @table.types.select { |type| @table.declared_in(type).include?(name) }
169
209
  end
170
210
 
171
- # The types that recognize this subgraph's schema: the ones it
172
- # declares, minus the roots every subgraph has.
211
+ # The types this subgraph's schema has to have: the ones it declares,
212
+ # minus the roots every subgraph has.
173
213
  def identifying_types(name) = declared_types(name) - ROOTS
174
214
 
175
- # Fields the supergraph says this subgraph resolves, but none of its
176
- # candidate schemas still defines. Every declared field, not only the
177
- # explicitly routed ones a field with no @join__field lives wherever
178
- # its type does, and dropping one is exactly the drift this looks for.
215
+ # What tells this subgraph apart: the coordinates the supergraph
216
+ # attributes to it and to nobody else. The entity two subgraphs extend
217
+ # is declared by both, so it says nothing about which of them a schema
218
+ # is taken as the whole of the evidence it matched an absent subgraph
219
+ # to its neighbour and called every field only the absent one resolves
220
+ # stale. A subgraph that shares everything it declares has no evidence
221
+ # at all, and is "not here" rather than guessed at.
222
+ def identifying(name)
223
+ types = declared_types(name).select { |type| @table.declared_in(type) == [name] } - ROOTS
224
+ types + @table.types.flat_map do |type|
225
+ @table.declared_fields(type).filter_map do |field|
226
+ "#{type}.#{field}" if @table.owners(type, field) == [name]
227
+ end
228
+ end
229
+ end
230
+
231
+ # Fields the supergraph says this subgraph resolves that its candidate
232
+ # schemas no longer define (stale), or define with another type
233
+ # (shape). Every declared field, not only the explicitly routed ones —
234
+ # a field with no @join__field lives wherever its type does, and
235
+ # dropping one is exactly the drift this looks for.
236
+ #
237
+ # A field can legitimately sit in more than one candidate (@shareable,
238
+ # an @external copy), so one agreeing schema settles it: the subgraph
239
+ # this supergraph describes is here somewhere.
179
240
  def record_stale(name, fitting)
180
241
  declared_types(name).each do |type_name|
181
242
  @table.declared_fields(type_name).each do |field_name|
182
243
  next unless @table.owners(type_name, field_name).include?(name)
183
244
 
184
245
  coordinate = "#{type_name}.#{field_name}"
185
- next if fitting.any? { |schema| GraphWeaver::Internal::Schemas.defines?(schema, coordinate) }
246
+ here = fitting.filter_map { |schema| GraphWeaver::Internal::Schemas.signature(schema, coordinate) }
247
+ if here.empty?
248
+ (@stale[coordinate] ||= []) << name
249
+ next
250
+ end
251
+
252
+ composed = @table.signature(type_name, field_name)
253
+ next if composed.nil? || here.include?(composed)
186
254
 
187
- (@stale[coordinate] ||= []) << name
255
+ entry = (@shape[coordinate] ||= { "subgraphs" => [], "supergraph" => composed, "here" => here.uniq })
256
+ entry["subgraphs"] << name
188
257
  end
189
258
  end
190
259
  end
@@ -221,6 +290,7 @@ module GraphWeaver
221
290
  def headline
222
291
  counts = [
223
292
  ("#{@stale.size} stale" if @stale.any?),
293
+ ("#{@shape.size} shape" if @shape.any?),
224
294
  ("#{@uncomposed.size} not composed in" if @uncomposed.any?),
225
295
  ].compact
226
296
  # "matches the schemas here" over nothing compared is the one verdict
@@ -234,6 +304,18 @@ module GraphWeaver
234
304
  "(checked #{@checked.size} of #{@table.subgraphs.size} subgraphs)"
235
305
  end
236
306
 
307
+ # both signatures, because neither alone says which way to move: the
308
+ # supergraph's is what callers are generating against, the local one is
309
+ # what would actually answer
310
+ def shape_section
311
+ return [] if @shape.empty?
312
+
313
+ ["", SHAPE, *@shape.sort.map do |coordinate, entry|
314
+ " #{coordinate} (#{entry["subgraphs"].join(", ")}): #{entry["supergraph"]} in the " \
315
+ "supergraph, #{entry["here"].join(", ")} here"
316
+ end]
317
+ end
318
+
237
319
  def section(title, entries)
238
320
  return [] if entries.empty?
239
321
 
@@ -245,9 +327,20 @@ module GraphWeaver
245
327
  def skipped_section
246
328
  return [] if @skipped.empty?
247
329
 
248
- ["", "not checked — nothing here defines what the supergraph says these declare " \
249
- "(running elsewhere, or the type is gone):",
250
- *@skipped.sort.map { |name, types| " #{name} (#{types.empty? ? "root types only" : types.join(", ")})" }]
330
+ ["", "not checked — no schema here matches what the supergraph says only these resolve " \
331
+ "(running elsewhere, or the subgraph is gone):",
332
+ *@skipped.sort.map { |name, what| " #{name} (#{evidence(what)})" }]
333
+ end
334
+
335
+ # how many coordinates the report names before it says "and N more"
336
+ SAMPLE = 5
337
+ private_constant :SAMPLE
338
+
339
+ def evidence(coordinates)
340
+ return "the supergraph attributes nothing to it alone" if coordinates.empty?
341
+ return coordinates.join(", ") if coordinates.size <= SAMPLE
342
+
343
+ "#{coordinates.first(SAMPLE).join(", ")} and #{coordinates.size - SAMPLE} more"
251
344
  end
252
345
 
253
346
  def faked_section
@@ -0,0 +1,315 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ # One schema and everything generation needs to know about it: where its
5
+ # queries live, where its Ruby goes, which client its modules call, what its
6
+ # custom scalars and enums mean, and the namespace that keeps its constants
7
+ # off another graph's.
8
+ #
9
+ # An app has at least one. The top-level settings (GraphWeaver.schema_path,
10
+ # queries_paths, generated_paths, types_module) describe it, so a single-schema
11
+ # app never says the word "graph" — and a second schema is a second graph
12
+ # rather than a second copy of the recipe.
13
+
14
+ module GraphWeaver
15
+ class Graph
16
+ # nil for the default graph — the one the settings describe, which has
17
+ # nothing to be called because there is nothing to tell it apart from.
18
+ attr_reader :name
19
+
20
+ # Each of these falls back to the matching top-level setting, so a graph
21
+ # says only what differs. output is one directory (a graph writes to one
22
+ # place); generated_paths stays the list of places to READ from.
23
+ def initialize(name: nil, schema: nil, queries: nil, output: nil, client: nil,
24
+ namespace: nil, types_module: nil, registrations: nil)
25
+ @name = name
26
+ @schema = schema
27
+ @queries = queries
28
+ @output = output
29
+ @client = client
30
+ @namespace = namespace
31
+ @types_module = types_module
32
+ @registrations = registrations
33
+ end
34
+
35
+ # The registrations this graph generates with: the top-level ones as they
36
+ # stand now, with this graph's own laid on top. Read here rather than
37
+ # captured at declaration, because in Rails the initializer that registers
38
+ # a scalar and the one that declares a graph run in alphabetical filename
39
+ # order — which can't be allowed to decide whether the registration lands.
40
+ # The default graph is the top-level registry.
41
+ def registry
42
+ return GraphWeaver::Codegen.registry unless @registrations
43
+
44
+ GraphWeaver::Codegen.registry.dup.tap do |registry|
45
+ registry.graph_name = @name
46
+ @registrations.each { |registration| replay(registry, registration) }
47
+ end
48
+ end
49
+
50
+ # One recorded registration, against this read's copy of the registry.
51
+ #
52
+ # A block-form extend_type mints a module whose constant name generated
53
+ # source spells, and a registration is replayed on every read — so the
54
+ # block runs once, at the declaration (GraphBuilder.build reads the
55
+ # registry there), and every read after replays the module it made.
56
+ def replay(registry, registration)
57
+ call, args, kwargs, block = registration
58
+ entry = registry.public_send(call, *args, **kwargs, &block)
59
+ return unless block && call == :extend_type
60
+
61
+ registration[1] = args + [entry[:mixins].last]
62
+ registration[3] = nil
63
+ end
64
+ private :replay
65
+
66
+ def queries = @queries || GraphWeaver.queries_paths
67
+ def output = @output || GraphWeaver.generated_paths.first
68
+ def client = @client
69
+
70
+ # Every constant this graph generates lives under `namespace:` — the query
71
+ # modules and the shared types module alike. Two schemas that each have a
72
+ # person.graphql, or that each hoist an enum, otherwise fight over one
73
+ # constant; this is the one knob that settles both.
74
+ def namespace = @namespace
75
+
76
+ def types_module
77
+ @types_module || (namespace ? "#{namespace}::#{GraphWeaver.types_module}" : GraphWeaver.types_module)
78
+ end
79
+
80
+ # The graphql-ruby schema, however it was named: a class, a Client, a path
81
+ # to a dump, SDL, or a callable returning one. Resolved each time rather
82
+ # than memoized — in dev the class object is replaced on reload, as
83
+ # Internal::Util.live_schema notes, and a callable is how an initializer
84
+ # names a class Zeitwerk hasn't loaded yet.
85
+ def schema
86
+ return GraphWeaver::Internal::Util.locate_schema! unless @schema
87
+
88
+ GraphWeaver::Internal::Util.schema_for(named_source)
89
+ end
90
+
91
+ # Whether this graph names its own schema. The default graph doesn't — it
92
+ # is whatever dump is at schema_path — which is what lets check_queries
93
+ # re-introspect that one and leave a named schema alone.
94
+ def named_schema? = !@schema.nil?
95
+
96
+ # The dump this graph's schema was named by, when it was named by a file —
97
+ # what a validation error's subgraph branding is read off. nil for a live
98
+ # class, a Client, or inline SDL.
99
+ def dump_path
100
+ path = named_dump_path
101
+ path if path && File.exist?(path)
102
+ end
103
+
104
+ # The dump this graph's schema NAMES, file or no file — dump_path once
105
+ # there is one, and where `schema:refresh` writes the first. A path is
106
+ # told from SDL by its extension, which is how SchemaLoader reads one
107
+ # anyway, and is the only question askable before the file exists.
108
+ def named_dump_path
109
+ return GraphWeaver::SchemaLoader.locate_path unless @schema
110
+
111
+ source = named_source
112
+ path = source.respond_to?(:to_path) ? source.to_path : source
113
+ path if path.is_a?(String) && GraphWeaver::SchemaLoader.dump_path?(path)
114
+ end
115
+
116
+ # What this graph's dump is derived FROM — what `schema:refresh`
117
+ # rewrites it from and `schema:diff` compares it against. The url the
118
+ # dump recorded, else the graphql-ruby schema class this process runs.
119
+ # nil when the dump is its own source: a committed artifact with nothing
120
+ # behind it to re-read.
121
+ #
122
+ # The recorded url wins because it is a fact the file states about
123
+ # itself, where a live class is an inference from whatever this app
124
+ # happens to execute against — and refreshing a foreign API's dump from
125
+ # the app's own schema would overwrite it with the wrong graph.
126
+ def dump_source
127
+ path = dump_path
128
+ # A dump this graph names but hasn't written yet has no provenance to
129
+ # read a source off, so the source is where its modules already post.
130
+ # That is what bootstraps a second graph's dump: URL= names one
131
+ # endpoint, and each graph has its own.
132
+ url = path ? GraphWeaver::SchemaLoader.provenance(path)&.dig("url") : (client_url if named_dump_path)
133
+ url || live_schema
134
+ end
135
+
136
+ # The url this graph's modules post to, or nil. `client:` holds a constant
137
+ # or its name — codegen spells it into source — so a name is resolved here
138
+ # the way the generated DEFAULT_CLIENT lambda resolves it; a graph baking
139
+ # none posts to the app default, which is where its modules go too.
140
+ def client_url
141
+ client = @client.is_a?(String) ? resolve_client! : @client
142
+ client ||= GraphWeaver.client
143
+ target = (client.transport if client.respond_to?(:transport)) || client
144
+ target.url if target.respond_to?(:url)
145
+ end
146
+ private :client_url
147
+
148
+ def resolve_client!
149
+ Object.const_get(@client)
150
+ rescue NameError
151
+ raise GraphWeaver::Error, "graph #{name.inspect} bakes client #{@client.inspect} into its " \
152
+ "modules and nothing defines that constant, so there is no endpoint to introspect " \
153
+ "#{named_dump_path} from"
154
+ end
155
+ private :resolve_client!
156
+
157
+ # The composed supergraph this graph plans against, or nil — the dump it
158
+ # names (for the default graph, the conventional one) when that dump
159
+ # carries the @join__* routing table. A graph whose schema is an API
160
+ # schema, or a live class, is in no supergraph of its own.
161
+ #
162
+ # One rule, asked by everything that needs one: the federation rake tasks
163
+ # report per graph off this, and Testing::Config resolves :router's
164
+ # supergraph through it.
165
+ def supergraph
166
+ path = dump_path
167
+ path if path && GraphWeaver::Internal::Util.composed?(path)
168
+ end
169
+
170
+ # The graphql-ruby schema class this graph runs in-process, or nil. The
171
+ # default graph's is the app client's (Internal::Util.live_schema).
172
+ def live_schema
173
+ return GraphWeaver::Internal::Util.live_schema unless @schema
174
+
175
+ source = named_source
176
+ source if source.is_a?(Class) && source <= GraphQL::Schema
177
+ end
178
+
179
+ # What `schema:` was given, with a callable called. A Proc is how an
180
+ # initializer names an autoloaded class; calling it here rather than at
181
+ # declaration is the whole point of allowing one — and the commonest way
182
+ # for one to be wrong is to answer nil (a config value that wasn't set, a
183
+ # guarded `defined?`, a safe_constantize). Refuse it here, where the graph
184
+ # and the setting are still in hand: dump_path, supergraph and live_schema
185
+ # all read this too, and every one of them would otherwise answer nil and
186
+ # send the blame somewhere else.
187
+ def named_source
188
+ source = @schema.respond_to?(:call) ? @schema.call : @schema
189
+ return source unless source.nil?
190
+
191
+ raise GraphWeaver::Error, "schema#{described} resolved to nil — schema takes a graphql-ruby " \
192
+ "schema class, a Client, a path to a dump, SDL, or a callable returning one"
193
+ end
194
+ private :named_source
195
+
196
+ # The module `path` generates, and the file it lands in. The namespace is
197
+ # the only thing a graph adds to the naming rule; the rest is the file name,
198
+ # as it is everywhere else.
199
+ def generated_names(path, source)
200
+ module_name, filename = GraphWeaver::Internal::Util.generated_names(path, source)
201
+ [namespace ? "#{namespace}::#{module_name}" : module_name, filename]
202
+ end
203
+
204
+ # How a message names this graph: " in graph :billing", or nothing at all
205
+ # for the default one, so a single-schema app's errors are unchanged.
206
+ def described = name ? " in graph #{name.inspect}" : ""
207
+ end
208
+
209
+ module Internal
210
+ # What a `GraphWeaver.graph` block is evaluated against: it collects the
211
+ # settings, runs the registrations, and refuses everything else — a typo
212
+ # is a mistake worth a message, not a call that vanishes.
213
+ #
214
+ # A separate object rather than the Graph itself, so a block can't reach a
215
+ # Graph's internals and a Graph stays a plain value. An app writes the
216
+ # block and never names this.
217
+ class GraphBuilder
218
+ # Everything a graph can say, in the order the docs teach it. `schema "x"`
219
+ # sets and bare `schema` reads back — there is no `schema =` form, because
220
+ # instance_eval would make that a local variable that silently does nothing.
221
+ SETTINGS = %i[schema queries output client namespace types_module].freeze
222
+ # The same three calls an app already writes at the top level, scoped here
223
+ # to this graph alone.
224
+ REGISTRATIONS = %i[register_scalar register_enum extend_type].freeze
225
+ # These three end up spelled in generated source, so each takes the
226
+ # constant or its name and stores the name.
227
+ CONSTANT_SETTINGS = %i[client namespace types_module].freeze
228
+ # …and these two are spelled as a `module` DEFINITION rather than a
229
+ # reference, which is why a root anchor is refused on them below.
230
+ MODULE_SETTINGS = %i[namespace types_module].freeze
231
+ private_constant :CONSTANT_SETTINGS, :MODULE_SETTINGS
232
+
233
+ attr_reader :settings, :registrations
234
+
235
+ # The Graph a block describes.
236
+ def self.build(name, &block)
237
+ builder = new(name)
238
+ builder.instance_eval(&block)
239
+ graph = GraphWeaver::Graph.new(name:, registrations: builder.registrations, **builder.settings)
240
+ # the block's registrations are applied at generation; run them once
241
+ # here so a bad one is a mistake in the block, said where it is written
242
+ graph.registry
243
+ graph
244
+ rescue NameError => e
245
+ # Ruby raises on the argument before the registration is ever called, so
246
+ # the block is the only place that can say why — and in Rails this is
247
+ # the commonest way to meet it. NoMethodError is a NameError too, and
248
+ # means something else entirely.
249
+ raise if e.is_a?(NoMethodError)
250
+
251
+ raise e.class, "#{e.message} — a graph block runs where it is written, so a registration " \
252
+ "in it stands where a top-level one does. #{GraphWeaver::Codegen::AUTOLOAD_HINT} " \
253
+ "Declare graph #{name.inspect} from one.", e.backtrace
254
+ end
255
+
256
+ def initialize(name)
257
+ @name = name
258
+ @settings = {}
259
+ # kept as calls rather than applied here: the graph replays them over
260
+ # the top-level registry at generation (see Graph#registry)
261
+ @registrations = []
262
+ end
263
+
264
+ SETTINGS.each do |setting|
265
+ define_method(setting) do |*value|
266
+ return @settings[setting] if value.empty?
267
+ raise ArgumentError, "#{setting} takes one value, got #{value.size}" if value.size > 1
268
+
269
+ @settings[setting] = GraphBuilder.constant_name(setting, value.first)
270
+ end
271
+ end
272
+
273
+ REGISTRATIONS.each do |registration|
274
+ define_method(registration) do |*args, **kwargs, &block|
275
+ @registrations << [registration, args, kwargs, block]
276
+ end
277
+ end
278
+
279
+ def method_missing(name, *, **, &) = raise(ArgumentError, refusal(name))
280
+
281
+ # Nothing reaches method_missing but a mistake, so the honest answer for
282
+ # every name it would catch is false.
283
+ def respond_to_missing?(name, _private = false) = false
284
+
285
+ # A Module where a constant's name goes says the same thing, and is what
286
+ # `client Billing::CLIENT` reads like. Anything else passes through:
287
+ # a schema is a path, SDL, a class, a Client, or a callable.
288
+ # On the singleton so the define_method setters above can reach it — srb
289
+ # reads a define_method block's self as the class.
290
+ def self.constant_name(setting, value)
291
+ return value unless CONSTANT_SETTINGS.include?(setting)
292
+
293
+ # Generated modules are defined at the top level, where a root anchor
294
+ # says nothing — and `module ::A::B` is not a name const_get can spell,
295
+ # so it used to surface as a verdict on the .graphql file's name.
296
+ if MODULE_SETTINGS.include?(setting) && value.is_a?(String) && value.start_with?("::")
297
+ raise ArgumentError, "#{setting} #{value.inspect}: drop the leading `::` — #{setting} " \
298
+ "names a module generated source defines, and it defines it at the top level either way"
299
+ end
300
+ return value unless value.is_a?(Module)
301
+
302
+ value.name || raise(ArgumentError, "#{setting} needs a constant — generated source has " \
303
+ "to spell it — and #{value.inspect} is anonymous")
304
+ end
305
+
306
+ def refusal(name)
307
+ takes = SETTINGS + REGISTRATIONS
308
+ near = GraphWeaver::Internal::Util.did_you_mean(takes.map(&:to_s), name.to_s)
309
+ "#{name} isn't something a graph block takes#{near ? " (did you mean #{near}?)" : ""} — " \
310
+ "graph #{@name.inspect} takes #{takes.join(", ")}"
311
+ end
312
+ private :refusal
313
+ end
314
+ end
315
+ end