graph_weaver 0.6.0 → 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 +1470 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +21 -7
  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 +498 -105
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +32 -4
  15. data/docs/scalars.md +286 -57
  16. data/docs/testing.md +458 -59
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +330 -5
  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 +218 -59
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +408 -206
  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 +43 -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 +490 -116
  70. metadata +56 -1
@@ -69,10 +69,86 @@ module GraphWeaver
69
69
  create_file "graphql.config.yml", editor_config
70
70
  end
71
71
 
72
+ # Generated files are machine-written and say "do not edit", but plain
73
+ # `rubocop` still fires Style/Documentation, Style/ClassAndModuleChildren
74
+ # and Metrics/* on every one of them. Only an app that already lints is
75
+ # touched: writing a .rubocop.yml would turn rubocop on for a project
76
+ # that never asked for it.
77
+ def exclude_generated_from_rubocop
78
+ return unless File.exist?(rubocop_config)
79
+
80
+ body = File.read(rubocop_config)
81
+ # already excluded — a re-run, or done by hand
82
+ globs = generated_globs.reject { |glob| body.include?(glob) }
83
+ return if globs.empty?
84
+
85
+ entries = globs.map { |glob| " - #{glob.inspect}" }.join("\n")
86
+ reason =
87
+ if body.match?(/^AllCops:/)
88
+ # rubocop takes the LAST of two duplicate keys, so appending a second
89
+ # AllCops: would replace the app's own rather than add to it
90
+ "sets AllCops already, and a second one would replace it rather than merge"
91
+ elsif yaml_documents(body) > 1
92
+ # rubocop reads only the first document, so the append lands where
93
+ # nothing will ever read it
94
+ "holds more than one YAML document, and rubocop reads only the first"
95
+ end
96
+
97
+ # Name the lines instead of guessing where inside theirs they belong.
98
+ if reason
99
+ say <<~TEXT
100
+
101
+ #{RUBOCOP_CONFIG} #{reason} — so add this under AllCops/Exclude:
102
+
103
+ #{entries}
104
+ TEXT
105
+ else
106
+ # inherit_mode is what makes this an addition: rubocop REPLACES an
107
+ # Exclude array on merge, so without it the block below wipes the
108
+ # effective list — rubocop's own vendor/node_modules/tmp defaults
109
+ # included, along with any Exclude reaching here through inherit_from.
110
+ append_to_file RUBOCOP_CONFIG, <<~YAML + entries + "\n"
111
+
112
+ # Machine-written by `rake graph_weaver:generate` — not yours to style.
113
+ AllCops:
114
+ inherit_mode:
115
+ merge:
116
+ - Exclude
117
+ Exclude:
118
+ YAML
119
+ end
120
+ end
121
+
122
+ # The `graphql:` tags need this require, and it has to be somewhere
123
+ # rspec actually loads. A spec/support file is not: rspec-rails ships
124
+ # the spec/support glob commented out, so the require sat there doing
125
+ # nothing and a tagged example silently ran against the real client.
126
+ def wire_rspec
127
+ helper = RSPEC_HELPERS.find { |path| File.exist?(File.join(GraphWeaver.root, path)) }
128
+
129
+ unless helper
130
+ say "\nTesting: add `#{RSPEC_REQUIRE}` to your spec helper for the " \
131
+ "`graphql:` tags (docs/testing.md)."
132
+ return
133
+ end
134
+
135
+ body = File.read(File.join(GraphWeaver.root, helper))
136
+ return if body.match?(REQUIRED_ALREADY) # a re-run, or done by hand
137
+
138
+ # after rspec-rails' own require where there is one, at the end
139
+ # otherwise — either way top-level in a file every spec loads
140
+ if (anchor = body[RSPEC_RAILS_REQUIRE])
141
+ insert_into_file helper, "#{RSPEC_REQUIRE}\n", after: anchor
142
+ else
143
+ append_to_file helper, "\n#{RSPEC_REQUIRE}\n"
144
+ end
145
+ end
146
+
72
147
  # A url is introspected and a schema class dumped; a dump the app
73
148
  # already has is left where it is (schema_path points at it instead).
74
149
  def fetch_schema
75
150
  return unless options[:schema] && form != :path
151
+ return if keep_existing_dump
76
152
 
77
153
  if form == :url
78
154
  # pass the var name, not just the token — it lands in the dump's
@@ -114,6 +190,33 @@ module GraphWeaver
114
190
 
115
191
  private
116
192
 
193
+ RUBOCOP_CONFIG = ".rubocop.yml"
194
+
195
+ # rails_helper first: rspec-rails writes both, and only rails_helper
196
+ # has Rails booted by the time the require runs.
197
+ RSPEC_HELPERS = ["spec/rails_helper.rb", "spec/spec_helper.rb"].freeze
198
+ RSPEC_REQUIRE = 'require "graph_weaver/rspec"'
199
+ # the newline is part of the anchor: Thor inserts directly after the
200
+ # match, so without it the require lands on the end of that line
201
+ RSPEC_RAILS_REQUIRE = %r{^require ["']rspec/rails["'].*\n}
202
+ REQUIRED_ALREADY = %r{^\s*require ["']graph_weaver/rspec["']}
203
+
204
+ def rubocop_config = File.join(GraphWeaver.root, RUBOCOP_CONFIG)
205
+
206
+ # Parsed, not counted: a `---` can also be a line inside a block scalar.
207
+ # A file rubocop itself can't read is left to rubocop to complain about.
208
+ def yaml_documents(body)
209
+ YAML.parse_stream(body).children.size
210
+ rescue Psych::SyntaxError
211
+ 1
212
+ end
213
+
214
+ # Every graph's output directory, so a multi-schema app is covered by
215
+ # the same run — read off the graphs rather than restated here.
216
+ def generated_globs
217
+ GraphWeaver.graphs.map { |graph| File.join(graph.output, "**/*") }.uniq
218
+ end
219
+
117
220
  # This install run is the one moment the user is guaranteed to be
118
221
  # reading, and a composed supergraph changes what the next steps are:
119
222
  # the test client is the interesting one, and there's a CI gate to add.
@@ -141,9 +244,13 @@ module GraphWeaver
141
244
 
142
245
  @subgraphs =
143
246
  begin
144
- (GraphWeaver::SchemaLoader.routing_table(source).subgraphs if form == :path)
247
+ # asked, not rescued: the refusal writes a warn line as it is
248
+ # built, and "this isn't a supergraph" is the ordinary answer
249
+ if form == :path && GraphWeaver::SchemaLoader.routing_table?(source)
250
+ GraphWeaver::SchemaLoader.routing_table(source).subgraphs
251
+ end
145
252
  rescue StandardError
146
- # not a supergraph, or not readable — nothing to say either way
253
+ # not readable — nothing to say either way
147
254
  nil
148
255
  end
149
256
  end
@@ -186,6 +293,33 @@ module GraphWeaver
186
293
 
187
294
  def auth_var = options[:auth] || GraphWeaver::SchemaLoader::DEFAULT_AUTH_ENV
188
295
 
296
+ # The dump is the one file the generator doesn't write through
297
+ # create_file, so Thor can't prompt on it — declining every conflict on
298
+ # a re-run still replaced it, and with it the source url it records.
299
+ # It is never overwritten here: `schema:refresh` is the command for
300
+ # that, and it re-fetches in place without touching anything else.
301
+ # True when there is one, having said so.
302
+ def keep_existing_dump
303
+ path = GraphWeaver::SchemaLoader.locate_path or return false
304
+
305
+ recorded = GraphWeaver::SchemaLoader.provenance(path)&.dig("url")
306
+ # a re-run naming a different endpoint would otherwise be answered
307
+ # silently by the dump the old one left
308
+ from = " (introspected from #{recorded})" if recorded && recorded != source
309
+ say_status :keep, "#{GraphWeaver::Internal::Util.relative(path)}#{from} — " \
310
+ "delete it and re-run to re-introspect", :yellow
311
+ true
312
+ end
313
+
314
+ # --auth is what says this API takes a token. Without it the line is
315
+ # shown rather than wired: a public API's initializer shouldn't read an
316
+ # ENV var nobody set, and the commented line is how you add one later.
317
+ def auth_setting
318
+ return %(auth: ENV["#{auth_var}"],) if options[:auth]
319
+
320
+ %(# auth: ENV["#{auth_var}"], # uncomment when the API needs a token)
321
+ end
322
+
189
323
  # The command just typed, retyped. One rule for every source form, and
190
324
  # the only one that always works: the files already written come back
191
325
  # "identical", and --auth rides along — where schema:refresh has no flag
@@ -202,7 +336,7 @@ module GraphWeaver
202
336
  # Custom scalars, enums and type mixins go here — `rake graph_weaver:generate`
203
337
  # bakes them into the generated source, so they must be registered first:
204
338
  #
205
- # GraphWeaver.register_scalar("DateTime", Time, serialize: :iso8601, requires: "time")
339
+ # GraphWeaver.register_scalar("Money", BigDecimal)
206
340
  # GraphWeaver.extend_type("Person", Greetable)
207
341
  RUBY
208
342
  end
@@ -215,7 +349,7 @@ module GraphWeaver
215
349
  <<~RUBY
216
350
  GraphWeaver.client = GraphWeaver.new(
217
351
  "#{source}",
218
- auth: ENV["#{auth_var}"],
352
+ #{auth_setting}
219
353
  cache: true, # reuse the committed dump; delete it to re-introspect
220
354
  )
221
355
  RUBY
@@ -45,7 +45,7 @@ class GraphWeaver::Client
45
45
  private_constant :CONTEXT_IN_PROCESS, :RETRY_RULE
46
46
 
47
47
  def initialize(source, auth: nil, headers: {}, transport: nil, cache: nil, ttl: nil,
48
- open_timeout: nil, read_timeout: nil, context: nil,
48
+ open_timeout: nil, read_timeout: nil, pool_size: nil, context: nil,
49
49
  retries: false, backoff: nil, base_delay: nil, max_delay: nil, jitter: nil, retry_on: nil,
50
50
  retry_if: nil, retry_codes: nil, retry_mutations: nil, sleeper: nil, &middleware)
51
51
  check_source!(source)
@@ -58,10 +58,11 @@ class GraphWeaver::Client
58
58
  if source.is_a?(String) && source.match?(URL)
59
59
  raise ArgumentError, CONTEXT_IN_PROCESS if context
60
60
 
61
- built = build_transport(source, auth:, headers:, kind: transport, open_timeout:, read_timeout:, &middleware)
61
+ built = build_transport(source, auth:, headers:, kind: transport, open_timeout:, read_timeout:, pool_size:,
62
+ &middleware)
62
63
  @transport = wrap_retries(built, retries, retry_options)
63
64
  else
64
- if auth || middleware || retries || open_timeout || read_timeout || !retry_options.empty?
65
+ if auth || middleware || retries || open_timeout || read_timeout || pool_size || !retry_options.empty?
65
66
  raise ArgumentError, "auth:/retries:/timeouts/middleware apply to a url — got a schema source"
66
67
  end
67
68
  if transport.is_a?(Symbol)
@@ -94,6 +95,27 @@ class GraphWeaver::Client
94
95
  @schema_lock = Mutex.new
95
96
  end
96
97
 
98
+ # Called by generated code — not semver'd for direct use.
99
+ #
100
+ # What actually runs a request, for anything in a client slot. A bare
101
+ # graphql-ruby schema class satisfies the execute contract on its own, so
102
+ # `client "Billing::Schema"`, `GraphWeaver.client = MyApp::Schema` and
103
+ # `execute!(client: MyApp::Schema)` all worked — and every one of them ran
104
+ # with no instrumentation seam at all: Schema.execute is not ours to
105
+ # bracket, so there was no APM event and no log line, not even at debug.
106
+ # One rule, applied wherever a client is read: a schema class gets the same
107
+ # InProcess wrapper GraphWeaver.new(Schema) builds. Everything else — a
108
+ # Client, a transport, a Retry, a fake — passes through untouched.
109
+ #
110
+ # Not memoized: the wrapper is two ivars beside a whole GraphQL execution,
111
+ # and in dev the class object is replaced on reload, so anything held onto
112
+ # would be the stale one.
113
+ def self.instrumented(client)
114
+ return client unless client.is_a?(Class) && client <= GraphQL::Schema
115
+
116
+ GraphWeaver::InProcess.new(client)
117
+ end
118
+
97
119
  # The transport queries run through: a url-built transport, an
98
120
  # explicit transport:, or the live schema class executing in-process.
99
121
  # Clients are self-contained — the app default never leaks in; nil for
@@ -165,15 +187,18 @@ class GraphWeaver::Client
165
187
  # it lets an unrelated gem swap your transport — along with its
166
188
  # timeouts and, since Faraday's default net_http adapter reconnects
167
189
  # per request, your connection reuse. Same code, same transport.
168
- def build_transport(url, auth:, headers:, kind:, open_timeout: nil, read_timeout: nil, &middleware)
190
+ def build_transport(url, auth:, headers:, kind:, open_timeout: nil, read_timeout: nil, pool_size: nil, &middleware)
169
191
  headers = headers.dup
170
192
  if auth
171
- unless auth.is_a?(String)
172
- raise ArgumentError, "auth: takes a token string, got #{auth.class} other headers go in " \
173
- "headers:, and a token that rotates goes in the Faraday middleware block"
193
+ unless auth.is_a?(String) || auth.respond_to?(:call)
194
+ raise ArgumentError, "auth: takes a token string, or something answering #call that returns " \
195
+ "one per request, got #{auth.class} other headers go in headers:"
174
196
  end
175
197
 
176
- headers["Authorization"] ||= auth.include?(" ") ? auth : "Bearer #{auth}"
198
+ # a callable stays callable: both transports resolve a header value per
199
+ # request, which is what a token that expires needs
200
+ headers["Authorization"] ||=
201
+ auth.respond_to?(:call) ? -> { bearer(auth.call) } : bearer(auth)
177
202
  end
178
203
 
179
204
  # nil means "the transport's default" — both bundled ones agree on it
@@ -181,15 +206,27 @@ class GraphWeaver::Client
181
206
 
182
207
  transport =
183
208
  if transport_kind(kind, middleware) == :faraday
209
+ # Faraday's adapter owns its connections; a pool ceiling here would be
210
+ # a number nothing reads, so say so instead of dropping it
211
+ raise ArgumentError, "pool_size: sizes the bundled HTTP transport's pool — Faraday's adapter " \
212
+ "manages its own connections, so configure it there" if pool_size
184
213
  build_faraday(url, headers:, timeouts:, &middleware)
185
214
  else
186
- GraphWeaver::Transport::HTTP.new(url, headers:, **timeouts)
215
+ GraphWeaver::Transport::HTTP.new(url, headers:, pool_size:, **timeouts)
187
216
  end
188
217
 
189
- GraphWeaver::Internal::Log.log(:info) { "transport: #{transport.class} -> #{url}" }
218
+ GraphWeaver::Internal::Log.log(:info) { "transport: #{transport.class} -> #{transport.safe_url}" }
190
219
  transport
191
220
  end
192
221
 
222
+ # "Bearer" is assumed unless the token carries its own scheme; nil is a
223
+ # token the caller declined to produce, and drops the header.
224
+ def bearer(token)
225
+ return if token.nil?
226
+
227
+ token.to_s.include?(" ") ? token.to_s : "Bearer #{token}"
228
+ end
229
+
193
230
  # Which bundled transport a url client builds: the explicit
194
231
  # transport:, else Faraday when a middleware block asks for it.
195
232
  def transport_kind(kind, middleware)
@@ -62,7 +62,7 @@ class GraphWeaver::Codegen
62
62
  # than the file.
63
63
  def check_alias_name!(node, name)
64
64
  taken = node.fields.any? { |f| f.prop == name } ||
65
- STRUCT_METHODS.include?(name) || ALIAS_RESERVED.include?(name) ||
65
+ RESERVED_PROPS.include?(name) || ALIAS_RESERVED.include?(name) ||
66
66
  RUBY_KEYWORDS.include?(name)
67
67
  return unless taken
68
68
 
@@ -72,10 +72,10 @@ class GraphWeaver::Codegen
72
72
 
73
73
  # Registered aliases for a GraphQL type (see extend_type alias:).
74
74
  def type_aliases(graphql_name)
75
- GraphWeaver::Codegen.type_registry[graphql_name]&.dig(:aliases) || {}
75
+ @registry.type_registry[graphql_name]&.dig(:aliases) || {}
76
76
  end
77
77
 
78
- # The CLASS methods a generated struct defines; STRUCT_METHODS covers the
78
+ # The CLASS methods a generated struct defines; RESERVED_PROPS covers the
79
79
  # instance side, and both are checked with RUBY_KEYWORDS alongside (all
80
80
  # three are defined by the class this mixes into).
81
81
  ALIAS_RESERVED = %w[from_h].to_set.freeze
@@ -163,10 +163,12 @@ class GraphWeaver::Codegen
163
163
  type = obj.graphql_type && @schema.get_type(obj.graphql_type)
164
164
  return unless type.respond_to?(:fields)
165
165
 
166
- known = type.fields.keys.map { |field| GraphWeaver::Inflect.underscore(field) }
166
+ # prop_name, not underscore: a path hops through PROPS, so a reserved
167
+ # field is spelled with its trailing underscore here too
168
+ known = type.fields.keys.map { |field| GraphWeaver::Codegen.prop_name(field) }
167
169
  return if seg == "__typename" || known.include?(seg)
168
170
 
169
- prop = GraphWeaver::Inflect.underscore(seg)
171
+ prop = GraphWeaver::Codegen.prop_name(seg)
170
172
  hint = if prop != seg && known.include?(prop)
171
173
  # paths are the Ruby prop chain, not the GraphQL one — the classic miss
172
174
  " — GraphQL fields generate snake_case props; use '#{prop}'"
@@ -111,6 +111,15 @@ class GraphWeaver::Codegen
111
111
  }.uniq
112
112
  end
113
113
 
114
+ # `module A::B` does not define A, so a namespaced graph's file opens each
115
+ # outer segment first. A line apiece rather than nesting the whole body:
116
+ # the generated Ruby is then identical but for these, so adding a
117
+ # namespace: later diffs as additions.
118
+ def emit_namespace(out)
119
+ parts = @name.split("::")
120
+ (1...parts.size).each { |i| out << "module #{parts.first(i).join("::")}; end" }
121
+ end
122
+
114
123
  def emit_shared_aliases(out, names, namespace)
115
124
  return if names.empty?
116
125
 
@@ -148,6 +157,7 @@ class GraphWeaver::Codegen
148
157
  requires.each { |req| out << "require #{req.inspect}" }
149
158
  out << ""
150
159
  end
160
+ emit_namespace(out)
151
161
  out << "module #{@name}; end"
152
162
  out << ""
153
163
  if inputs.any?
@@ -184,6 +194,7 @@ class GraphWeaver::Codegen
184
194
  out << ""
185
195
  out << "# Generated by GraphWeaver #{GraphWeaver::VERSION} — do not edit."
186
196
  out << ""
197
+ emit_namespace(out)
187
198
  out << "module #{@name}"
188
199
  yield(out)
189
200
  out << "end"
@@ -225,6 +236,7 @@ class GraphWeaver::Codegen
225
236
  out << "require_relative \"types\""
226
237
  out << ""
227
238
  end
239
+ emit_namespace(out)
228
240
  out << "module #{@name}"
229
241
  out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
230
242
  # a GraphQL block string could contain a bare GRAPHQL line, which
@@ -233,7 +245,13 @@ class GraphWeaver::Codegen
233
245
  delimiter = "GRAPHQL"
234
246
  delimiter += "_" while @query.match?(/^\s*#{delimiter}\s*$/)
235
247
  out << " QUERY = T.let(<<~'#{delimiter}', String)"
236
- @query.each_line { |line| out << " #{line}".rstrip }
248
+ # only the line's own newline comes off: trailing whitespace is part of a
249
+ # block-string argument's value, and <<~ takes the indent back off again.
250
+ # An empty line has no content to indent, so it stays empty.
251
+ @query.each_line do |line|
252
+ content = line.chomp
253
+ out << (content.empty? ? "" : " #{content}")
254
+ end
237
255
  out << " #{delimiter}"
238
256
  out << ""
239
257
  out << " # sent as the request's operationName — what an APM keys traces on"
@@ -345,6 +363,7 @@ class GraphWeaver::Codegen
345
363
  out << "#{pad}class #{node.class_name} < T::Struct"
346
364
  out << "#{pad} extend T::Sig" if GraphWeaver.extend_t_sig?
347
365
  out << "#{pad} include GraphWeaver::Hints"
366
+ out << "#{pad} include GraphWeaver::ResultStruct"
348
367
  node.mixins.each do |mixin|
349
368
  out << "#{pad} include #{mixin} # registered for #{node.graphql_type}"
350
369
  end
@@ -362,6 +381,8 @@ class GraphWeaver::Codegen
362
381
  end
363
382
 
364
383
  node.fields.each do |field|
384
+ note = renamed_note(field.prop, field.key)
385
+ out << "#{pad} #{note}" if note
365
386
  out << "#{pad} const :#{field.prop}, #{field.node.prop_type}"
366
387
  end
367
388
 
@@ -376,10 +397,23 @@ class GraphWeaver::Codegen
376
397
  out << "#{pad} rescue GraphWeaver::Error"
377
398
  out << "#{pad} raise # already branded by a nested struct or leaf — keep the innermost context"
378
399
  out << "#{pad} rescue StandardError => e" # sorbet's prop check, mostly
379
- out << "#{pad} raise GraphWeaver::TypeError.new(struct: self, " \
400
+ out << "#{pad} raise GraphWeaver::CastError.new(struct: self, " \
380
401
  "message: GraphWeaver::Hints.cast_message(self, data, e))"
381
402
  out << "#{pad} end"
382
403
 
404
+ # the mirror of from_h: the response keys, each leaf back through its
405
+ # scalar's serialize:, so from_h reads what as_json wrote. Rails calls
406
+ # it for `render json:`, and ResultStruct#to_json goes through it.
407
+ out << ""
408
+ out << "#{pad} sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }"
409
+ out << "#{pad} def as_json(*_options)"
410
+ out << "#{pad} {"
411
+ node.fields.each do |field|
412
+ out << "#{pad} #{field.key.inspect} => #{field_json(field)},"
413
+ end
414
+ out << "#{pad} }"
415
+ out << "#{pad} end"
416
+
383
417
  # alias delegators (extend_type alias:) — typed accessors that project a
384
418
  # selected field onto the struct, next to the honest wire data
385
419
  node.aliases.each do |a|
@@ -396,7 +430,7 @@ class GraphWeaver::Codegen
396
430
  out << "#{pad}module #{node.class_name}"
397
431
  out << "#{pad} extend T::Sig" << "" if GraphWeaver.extend_t_sig?
398
432
 
399
- structs = node.members.values + [node.catch_all].compact
433
+ structs = node.members.values + [node.catch_all]
400
434
  structs.each do |member|
401
435
  emit_object(member, out, indent + 1)
402
436
  out << ""
@@ -408,21 +442,14 @@ class GraphWeaver::Codegen
408
442
  out << ""
409
443
  out << "#{pad} sig { params(data: T::Hash[String, T.untyped]).returns(Type) }"
410
444
  out << "#{pad} def self.from_h(data)"
411
- if node.catch_all
412
- out << "#{pad} case data.fetch(\"__typename\")"
413
- else
414
- out << "#{pad} case (typename = data.fetch(\"__typename\"))"
415
- end
445
+ out << "#{pad} case data.fetch(\"__typename\")"
416
446
  node.members.each do |graphql_name, member|
417
447
  out << "#{pad} when #{graphql_name.inspect} then #{member.class_name}.from_h(data)"
418
448
  end
419
- if node.catch_all
420
- out << "#{pad} # a member this query names no fields on — including one the"
421
- out << "#{pad} # schema grew since generation"
422
- out << "#{pad} else #{node.catch_all.class_name}.from_h(data)"
423
- else
424
- out << "#{pad} else raise GraphWeaver::TypeError.new(struct: self, message: \"unexpected __typename: \#{typename}\")"
425
- end
449
+ # every dispatch has a catch-all, so no __typename is unexpected
450
+ out << "#{pad} # a member this query names no fields on — including one the"
451
+ out << "#{pad} # schema grew since generation"
452
+ out << "#{pad} else #{node.catch_all.class_name}.from_h(data)"
426
453
  out << "#{pad} end"
427
454
  out << "#{pad} end"
428
455
  out << "#{pad}end"
@@ -432,6 +459,13 @@ class GraphWeaver::Codegen
432
459
  # client/client= carry no per-query types, so they live in the gem
433
460
  out << " # client / client= — see GraphWeaver::QueryModule"
434
461
  out << " extend GraphWeaver::QueryModule"
462
+ if @graph_name
463
+ out << ""
464
+ out << " # the graph this module was generated from — what a test mode builds"
465
+ out << " # its stand-in client from"
466
+ out << " GRAPH = T.let(#{@graph_name.inspect}, Symbol)"
467
+ out << " private_constant :GRAPH"
468
+ end
435
469
  if @client_const
436
470
  out << ""
437
471
  out << " # the baked default client, resolved on first use"
@@ -474,12 +508,15 @@ class GraphWeaver::Codegen
474
508
  flag ? "#{var.kwarg}: (#{flag} = true; nil)" : "#{var.kwarg}: nil"
475
509
  } + ["client: nil"]
476
510
 
477
- call = "client_for(client).execute(QUERY, variables:, operation_name: OPERATION_NAME)"
511
+ # QueryModule#dispatch reads QUERY/OPERATION_NAME/GRAPH off the module,
512
+ # so the gem gets to bracket every request without a line of it landing
513
+ # in every generated file
514
+ call = "dispatch(variables, client:)"
478
515
 
479
516
  # execute returns the full envelope; execute! is the strict shortcut for
480
517
  # the typed result, or a raised QueryError.
481
- out << " # .checked(:never): an untyped value (a Rails param) reaches the coercion below"
482
- out << " # instead of sorbet-runtime's argument check; srb tc still holds typed call sites."
518
+ out << " # .checked(:never): an untyped value (a Rails param) reaches the coercion"
519
+ out << " # below instead of being rejected by sorbet-runtime's argument check."
483
520
  out << " sig { params(#{sig_params.join(", ")}).returns(GraphWeaver::Response[Result]).checked(:never) }"
484
521
  out << " def self.execute(#{kwargs.join(", ")})"
485
522
  emit_variables(out, required, optional, omitted)
@@ -558,16 +595,16 @@ class GraphWeaver::Codegen
558
595
 
559
596
  # A kwarg's trip onto the wire: normalize whatever arrived into the type
560
597
  # the sig promises — the sig itself is `.checked(:never)`, so this is the
561
- # check — then serialize. Coercion is wrapped so a refusal names the
562
- # variable and the operation; the value alone locates nothing.
598
+ # check — then serialize. The whole trip runs inside Coerce.variable, so a
599
+ # refusal from either half names the variable and the operation; the value
600
+ # alone locates nothing. (A serializer raising used to escape bare: a cast
601
+ # that answers nil hands `nil.upcase` to whatever called execute.)
563
602
  def variable_serialize(var)
564
- value = if var.node.coerce?
565
- "GraphWeaver::Coerce.variable(#{var.wire.inspect}, OPERATION_NAME, #{var.kwarg}) " \
566
- "{ |v| #{var.node.coerce("v")} }"
567
- else
568
- var.kwarg
569
- end
570
- var.node.serialize_identity? ? value : var.node.serialize(value, 1)
603
+ return var.kwarg if !var.node.coerce? && var.node.serialize_identity?
604
+
605
+ value = var.node.coerce? ? var.node.coerce("v") : "v"
606
+ value = var.node.serialize(value, 1) unless var.node.serialize_identity?
607
+ "GraphWeaver::Coerce.variable(#{var.wire.inspect}, OPERATION_NAME, #{var.kwarg}) { |v| #{value} }"
571
608
  end
572
609
 
573
610
  def field_cast(field)
@@ -588,6 +625,30 @@ class GraphWeaver::Codegen
588
625
  "GraphWeaver::Hints.field(self, #{field.key.inspect}) { #{cast} }"
589
626
  end
590
627
 
628
+ # One prop written back the way the server spelled it. The inverse of
629
+ # field_cast, minus its branding: rendering a value the library already
630
+ # cast can't fail on the value, only on a registration whose serialize:
631
+ # raises — which is the app's own code and says so.
632
+ def field_json(field)
633
+ node = field.node
634
+ # a prop is only ever read off a receiver, which is what lets `next` and
635
+ # `end` be props at all (see RUBY_KEYWORDS) — so here the receiver is us
636
+ prop = RUBY_KEYWORDS.include?(field.prop) ? "self.#{field.prop}" : field.prop
637
+ return prop if node.serialize_identity?
638
+ return node.serialize(prop, 1) if node.non_null?
639
+
640
+ "#{prop}&.then { |v1| #{node.serialize("v1", 2)} }"
641
+ end
642
+
643
+ # Why a prop is spelled unlike its wire name, for the one case a reader
644
+ # can't infer: a reserved name took a trailing underscore. camelCase →
645
+ # snake_case is the rule the whole file follows, and the wire key is
646
+ # already spelled out beside it (from_h's `data[...]`, a FIELDS row), so
647
+ # noting every prop would be noise.
648
+ def renamed_note(prop, wire)
649
+ "# wire: #{wire} — reserved as a prop name" if prop == "#{underscore(wire)}_"
650
+ end
651
+
591
652
  # A module-level T::Struct per input type: typed consts plus a FIELDS
592
653
  # table the GraphWeaver::InputStruct runtime drives — serialize/to_h/
593
654
  # coerce live once in the gem, not unrolled per struct (bool_exp
@@ -599,6 +660,11 @@ class GraphWeaver::Codegen
599
660
  out << "#{pad} include GraphWeaver::InputStruct"
600
661
  out << "#{pad} extend GraphWeaver::InputStruct::ClassMethods"
601
662
  out << ""
663
+ # the schema's name for this type, so a refusal reports GraphQL
664
+ # vocabulary rather than the Ruby class generation happened to pick
665
+ out << "#{pad} GRAPHQL_NAME = T.let(#{node.graphql_name.inspect}, String)"
666
+ out << "#{pad} private_constant :GRAPHQL_NAME"
667
+ out << ""
602
668
  if node.one_of
603
669
  out << "#{pad} # @oneOf: every field is nullable, so exactly-one is checked at runtime"
604
670
  out << "#{pad} ONE_OF = T.let(true, T::Boolean)"
@@ -612,15 +678,18 @@ class GraphWeaver::Codegen
612
678
  type = field.node.prop_type
613
679
  type = "T.nilable(#{type})" if !field.required && field.node.non_null? && type != "T.untyped"
614
680
  default = field.required ? "" : ", default: nil"
681
+ note = renamed_note(field.prop, field.wire)
682
+ out << "#{pad} #{note}" if note
615
683
  out << "#{pad} const :#{field.prop}, #{type}#{default}"
616
684
  end
617
685
  out << ""
618
- out << "#{pad} # (prop, wire, required, serializer, coercer) per field"
686
+ out << "#{pad} # (prop, wire, required, serializer, coercer, coordinate) per field"
619
687
  out << "#{pad} FIELDS = T.let(["
620
688
  node.fields.each do |field|
621
689
  serializer = field.node.serialize_identity? ? "nil" : "->(v) { #{field.node.serialize("v", 1)} }"
622
690
  coercer = field.node.hash_coerce_identity? ? "nil" : "->(v) { #{field.node.hash_coerce("v", 1)} }"
623
- out << "#{pad} GraphWeaver::InputStruct::Field.new(:#{field.prop}, #{field.wire.inspect}, #{field.required}, #{serializer}, #{coercer}),"
691
+ coordinate = "#{node.graphql_name}.#{field.wire}"
692
+ out << "#{pad} GraphWeaver::InputStruct::Field.new(:#{field.prop}, #{field.wire.inspect}, #{field.required}, #{serializer}, #{coercer}, #{coordinate.inspect}),"
624
693
  end
625
694
  out << "#{pad} ].freeze, T::Array[GraphWeaver::InputStruct::Field])"
626
695
  # InputStruct reads it with const_get, which privacy doesn't block
@@ -78,7 +78,8 @@ class GraphWeaver::Codegen
78
78
  end
79
79
  end
80
80
 
81
- class << self
81
+ # The enum half of one graph's registrations — see Codegen::Registry.
82
+ class Registry
82
83
  # Map a GraphQL enum onto an app-owned T::Enum (see EnumType). The one
83
84
  # implementation — GraphWeaver.register_enum is a delegate, so the same
84
85
  # call reaches it whichever door you came in by.