graph_weaver 0.4.6 → 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 +1314 -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 -154
  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 -258
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -124
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +593 -334
  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 +9 -1
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +14 -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 +7 -0
  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 +794 -59
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +43 -8
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +160 -61
  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 +181 -21
  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 +28 -10
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +67 -14
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +389 -170
  62. metadata +20 -3
@@ -2,6 +2,8 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "sorbet-runtime"
5
+ require "time" # Time.httpdate, for Retry-After
6
+
5
7
  require_relative "inflect"
6
8
  require_relative "logging"
7
9
 
@@ -12,8 +14,9 @@ module GraphWeaver
12
14
  # structured failures to users. One subclass per failure site —
13
15
  # {TransportError} (never reached the server), {ServerError} (non-2xx),
14
16
  # {QueryError} (GraphQL-level errors), {TypeError} (response wouldn't
15
- # cast), {InputError} (bad variables), {ValidationError} (build time)
16
- # each merging its specifics into #to_h.
17
+ # cast), {InputError} (bad variables), {ValidationError} (build time),
18
+ # {ConfigurationError} (setup judged against your schema) — each merging
19
+ # its specifics into #to_h.
17
20
  class Error < StandardError
18
21
  extend T::Sig
19
22
 
@@ -85,17 +88,49 @@ module GraphWeaver
85
88
  sig { returns(T.untyped) }
86
89
  attr_reader :body
87
90
 
88
- sig { params(status: Integer, body: T.untyped).void }
89
- def initialize(status:, body: nil)
91
+ # The response headers, names downcased — the rate-limit budget
92
+ # (x-ratelimit-remaining), the request id your provider wants in a
93
+ # support ticket, Retry-After. Empty when the transport had none.
94
+ sig { returns(T::Hash[String, String]) }
95
+ attr_reader :headers
96
+
97
+ sig { params(status: Integer, body: T.untyped, headers: T::Hash[String, String]).void }
98
+ def initialize(status:, body: nil, headers: {})
90
99
  @status = status
91
100
  @body = body
101
+ @headers = headers
92
102
  snippet = body.to_s.empty? ? "" : ": #{body.to_s[0, 500]}"
93
103
  super("HTTP #{status}#{snippet}")
94
104
  end
95
105
 
106
+ # Seconds to wait per the server's Retry-After, which is either a
107
+ # delay in seconds or an HTTP-date. nil when absent or unparseable;
108
+ # negative dates (already past) clamp to 0. See RFC 9110 §10.2.3.
109
+ sig { returns(T.nilable(Float)) }
110
+ def retry_after
111
+ value = headers["retry-after"]&.strip
112
+ return if value.nil? || value.empty?
113
+ return value.to_f if value.match?(/\A\d+(\.\d+)?\z/)
114
+
115
+ seconds = Time.httpdate(value) - Time.now
116
+ [seconds, 0.0].max
117
+ rescue ArgumentError
118
+ nil
119
+ end
120
+
121
+ # True when the server said "you're going too fast" — 429, or the
122
+ # 503 + Retry-After that some gateways send instead. Same question,
123
+ # same name, as QueryError#throttled?: an API may answer either way.
124
+ sig { returns(T::Boolean) }
125
+ def throttled?
126
+ status == 429 || (status == 503 && !retry_after.nil?)
127
+ end
128
+
96
129
  sig { override.returns(T::Hash[String, T.untyped]) }
97
130
  def to_h
98
- super.merge("status" => status)
131
+ # the raw headers stay off the machine side — Set-Cookie and
132
+ # friends don't belong in a log line; read #headers for those
133
+ super.merge("status" => status, "retry_after" => retry_after).compact
99
134
  end
100
135
  end
101
136
 
@@ -171,6 +206,23 @@ module GraphWeaver
171
206
  code == "GRAPHQL_VALIDATION_FAILED" || VALIDATION_MESSAGE.match?(message)
172
207
  end
173
208
 
209
+ # The codes servers use to say "you're going too fast". No standard
210
+ # exists, so this is the union of what the big graphs actually send:
211
+ # Shopify THROTTLED, GitHub RATE_LIMITED, Apollo/Hasura the rest.
212
+ # Pass it to Retry (retry_codes:) rather than hand-writing strings.
213
+ THROTTLE_CODES = T.let(
214
+ %w[THROTTLED RATE_LIMITED RATE_LIMIT_EXCEEDED TOO_MANY_REQUESTS REQUEST_LIMIT_EXCEEDED].freeze,
215
+ T::Array[String],
216
+ )
217
+
218
+ # True when this error is the GraphQL-level equivalent of a 429 —
219
+ # the same question ServerError#throttled? asks of an HTTP status,
220
+ # since an API may answer either way.
221
+ sig { returns(T::Boolean) }
222
+ def throttled?
223
+ THROTTLE_CODES.include?(code)
224
+ end
225
+
174
226
  # The field the error points at, as a stable dotted path with list
175
227
  # indices stripped — ["people", 3, "email"] => "people.email". The
176
228
  # parseable key for grouping/reporting (the raw #path keeps indices).
@@ -249,6 +301,15 @@ module GraphWeaver
249
301
  errors.any?(&:validation?)
250
302
  end
251
303
 
304
+ # True when the server said "you're going too fast" in the errors
305
+ # array rather than in an HTTP status — back off and retry, don't
306
+ # rewrite the query. Same name as ServerError#throttled?, because an
307
+ # API may answer either way and callers shouldn't have to care which.
308
+ sig { returns(T::Boolean) }
309
+ def throttled?
310
+ errors.any?(&:throttled?)
311
+ end
312
+
252
313
  # Errors grouped by the field they point at (index-stripped dotted
253
314
  # path; nil key for global errors) — iterate with each_error:
254
315
  #
@@ -312,7 +373,7 @@ module GraphWeaver
312
373
  end
313
374
 
314
375
  # Raised when a GraphQL response carried top-level errors and the caller
315
- # demanded data (Response#data!, or the one-shot GraphWeaver.execute).
376
+ # demanded data (Response#data!, or the one-shot GraphWeaver.run!).
316
377
  # Carries the structured errors, any partial data, and top-level
317
378
  # extensions (cost/throttle metadata).
318
379
  class QueryError < Error
@@ -354,19 +415,28 @@ module GraphWeaver
354
415
  def to_h
355
416
  super.merge(
356
417
  "schema_stale" => schema_stale?,
418
+ "throttled" => throttled?,
357
419
  "codes" => codes,
358
420
  "errors" => errors.map(&:to_h),
359
421
  "extensions" => extensions,
360
422
  )
361
423
  end
362
424
 
425
+ # what a validation-shaped rejection means, and the way out of it
426
+ DRIFT_HINT = T.let(
427
+ "the server rejected the query shape: the schema may have changed since generation; " \
428
+ "refresh the schema dump and regenerate " \
429
+ "(rake graph_weaver:schema:refresh && rake graph_weaver:generate)",
430
+ String,
431
+ )
432
+
363
433
  private
364
434
 
365
435
  sig { returns(String) }
366
436
  def summary
367
437
  first = errors.first
368
- more = errors.size > 1 ? " (and #{errors.size - 1} more)" : ""
369
- drift = schema_stale? ? " — the server rejected the query shape: the schema may have changed since generation; refresh the schema dump and regenerate (rake graph_weaver:schema:refresh && rake graph_weaver:generate)" : ""
438
+ more = " (and #{errors.size - 1} more)" if errors.size > 1
439
+ drift = " — #{DRIFT_HINT}" if schema_stale?
370
440
  "GraphQL query failed: #{first}#{more}#{drift}"
371
441
  end
372
442
  end
@@ -383,10 +453,16 @@ module GraphWeaver
383
453
  sig { returns(T.untyped) }
384
454
  attr_reader :struct
385
455
 
456
+ # sorbet-runtime appends its own frame to a prop type error ("Caller:
457
+ # .../call_validation.rb:331"), which is a path into the gem and never
458
+ # into the code with the problem — so it is dropped rather than reprinted
459
+ # as if it located anything.
460
+ SORBET_CALLER = /\s*\nCaller: .*\z/m
461
+
386
462
  sig { params(struct: T.untyped, error: T.nilable(Exception), message: T.nilable(String)).void }
387
463
  def initialize(struct:, error: nil, message: nil)
388
464
  @struct = struct
389
- super("failed to cast response into #{struct}: #{message || error&.message}")
465
+ super("failed to cast response into #{struct}: #{message || error&.message&.sub(SORBET_CALLER, "")}")
390
466
  end
391
467
 
392
468
  sig { override.returns(T::Hash[String, T.untyped]) }
@@ -424,6 +500,15 @@ module GraphWeaver
424
500
  end
425
501
  end
426
502
 
503
+ # The setup doesn't add up — judged against your schema, not against the
504
+ # shape of an argument. Which Ruby schema serves which subgraph is the
505
+ # case that exists: two schemas fit one subgraph, or the one you named
506
+ # doesn't define what the supergraph says that subgraph resolves. A
507
+ # verdict the library reached, so it's under the Error umbrella and a
508
+ # spec helper can rescue it; a plainly wrong argument (`pool_size: must
509
+ # be >= 1`) stays an ArgumentError, as in any Ruby method.
510
+ class ConfigurationError < Error; end
511
+
427
512
  # Build-time: the query didn't validate against the schema. Carries the
428
513
  # structured validation errors (message + line/column) rather than a
429
514
  # joined string. Under the Error umbrella like everything else raised
@@ -437,12 +522,44 @@ module GraphWeaver
437
522
  sig { params(errors: T::Array[T::Hash[Symbol, T.untyped]]).void }
438
523
  def initialize(errors)
439
524
  @errors = errors
440
- super("invalid query: #{errors.map { |e| e[:message] }.join("; ")}")
525
+ super(render(errors))
441
526
  end
442
527
 
443
528
  sig { override.returns(T::Hash[String, T.untyped]) }
444
529
  def to_h
445
530
  super.merge("errors" => errors.map { |e| e.transform_keys(&:to_s) })
446
531
  end
532
+
533
+ # "queries/person.graphql:4:5 Field 'nmae' …" back into its three parts —
534
+ # [path, "line:column", message]. Codegen folds the position (and, when it
535
+ # knows it, the file) into :message, so anything reporting the parts
536
+ # separately splits it back out here rather than growing a second splitter
537
+ # to disagree with. A message with no such prefix passes through whole.
538
+ sig { params(error: T::Hash[Symbol, T.untyped]).returns([T.nilable(String), String, String]) }
539
+ def self.split(error)
540
+ message = error[:message].to_s
541
+ position = [error[:line], error[:column]].compact.join(":")
542
+ return [nil, position, message] if position.empty?
543
+
544
+ match = message.match(/\A(?:(?<path>.+):)?#{Regexp.escape(position)} (?<rest>.*)\z/m)
545
+ match ? [match[:path], position, match[:rest]] : [nil, position, message]
546
+ end
547
+
548
+ private
549
+
550
+ # Compiler-style: the query file once in the header, then one error per
551
+ # line — thirty typos on one joined line is a wall nobody reads.
552
+ sig { params(errors: T::Array[T::Hash[Symbol, T.untyped]]).returns(String) }
553
+ def render(errors)
554
+ entries = errors.map { |error| ValidationError.split(error) }
555
+ paths = entries.map(&:first).compact.uniq
556
+ hoisted = paths.one?
557
+
558
+ lines = entries.map do |path, position, message|
559
+ prefix = [(path unless hoisted), position].reject { |part| part.nil? || part.empty? }.join(":")
560
+ prefix.empty? ? " #{message}" : " #{prefix} #{message}"
561
+ end
562
+ [hoisted ? "invalid query in #{paths.first}:" : "invalid query:", *lines].join("\n")
563
+ end
447
564
  end
448
565
  end
@@ -0,0 +1,272 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "graphql"
5
+
6
+ require_relative "schema_loader"
7
+ require_relative "schemas"
8
+
9
+ module GraphWeaver
10
+ # Federation checks that need no network — the supergraph you committed,
11
+ # read against the subgraph schemas running in this process.
12
+ module Federation
13
+ # Has someone changed a subgraph without recomposing the supergraph?
14
+ #
15
+ # rake graph_weaver:federation:diff SUPERGRAPH=supergraph.graphql
16
+ #
17
+ # A committed supergraph is a snapshot of a composition. Change a
18
+ # subgraph and skip the recompose and it quietly describes a graph that
19
+ # no longer exists — the failure this catches, locally and before merge,
20
+ # where {SchemaLoader.stale?} needs the server and answers a different
21
+ # question (has the *server* drifted from my dump).
22
+ #
23
+ # Both directions, because they mean opposite things:
24
+ #
25
+ # - **stale** — the supergraph carries `Product.weight` and no schema
26
+ # here defines it any more. Recompose.
27
+ # - **not composed in** — a schema here defines `Product.dimensions` and
28
+ # the supergraph doesn't carry it. Publish the subgraph.
29
+ #
30
+ # What "defines" means: a coordinate is compared only against the
31
+ # schemas that could *be* the subgraph the supergraph attributes it to —
32
+ # the ones defining every non-root type it declares. Exact field-set
33
+ # equality would be too strict in both directions: a subgraph carries
34
+ # federation plumbing (`_entities`, `_service`) the supergraph never
35
+ # has, and a field can legitimately sit in more than one subgraph
36
+ # (`@external` copies, `@shareable`). So the uncomposed side reports
37
+ # only a field the supergraph's type doesn't carry **at all** — not one
38
+ # it merely attributes elsewhere — and underscore-prefixed fields never
39
+ # count.
40
+ #
41
+ # A supergraph is routinely only **partly local** — the rest served by
42
+ # another process, or answered with fabricated data ({Testing::Subgraphs}
43
+ # `=> :fake`). Neither can be compared against anything, so the report
44
+ # names three states rather than two: checked, not here, and faked. A
45
+ # clean result that didn't say what it couldn't see would be actively
46
+ # misleading on the graphs this is for.
47
+ class Drift
48
+ # 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.
51
+ ROOTS = %w[Query Mutation Subscription].freeze
52
+
53
+ # { "Product.weight" => ["products"] } — the supergraph says these
54
+ # subgraphs resolve it, and no schema of theirs here defines it
55
+ attr_reader :stale
56
+
57
+ # { "Product.dimensions" => ["Products::Schema"] } — defined here,
58
+ # absent from the supergraph
59
+ attr_reader :uncomposed
60
+
61
+ # { "inventory" => ["Warehouse"] } — subgraph => the types that would
62
+ # identify it, which nothing here defines
63
+ attr_reader :skipped
64
+
65
+ # subgraphs answered with fabricated data, so there's no real schema
66
+ # behind them to compare against
67
+ attr_reader :faked
68
+
69
+ # every subgraph that was actually compared
70
+ attr_reader :checked
71
+
72
+ # supergraph: the composed SDL (a path or the content); defaults to
73
+ # the conventional dump. subgraphs: the same map {Testing::Router}
74
+ # takes — a named schema skips detection, `:fake` (like anything else
75
+ # that isn't a schema class) says there's nothing real to compare.
76
+ # schemas: overrides which loaded schemas detection searches — by
77
+ # default every named GraphQL::Schema in the process.
78
+ def initialize(supergraph: nil, subgraphs: nil, schemas: nil)
79
+ source = (supergraph || GraphWeaver::SchemaLoader.locate_path).to_s
80
+ @table = GraphWeaver::SchemaLoader.routing_table(source)
81
+ # SDL passed as content has no name to print
82
+ @source = source.include?("\n") ? "the supergraph" : source
83
+ @given = named(subgraphs)
84
+ @schemas = schemas || GraphWeaver::Schemas.loaded
85
+ @stale = {}
86
+ @uncomposed = {}
87
+ @skipped = {}
88
+ @faked = []
89
+ @checked = []
90
+ compare
91
+ end
92
+
93
+ # whether the supergraph and the code here disagree — what CI gates on
94
+ def drift? = @stale.any? || @uncomposed.any?
95
+
96
+ # Nothing was compared, so "no drift" is vacuous: the gate would pass
97
+ # whatever the subgraphs said. Categorically different from "checked 3
98
+ # of 4" — that one checked something, and a partly-local supergraph is
99
+ # a supported setup.
100
+ def vacuous? = @checked.empty?
101
+
102
+ # JSON-ready: the drift, keyed by coordinate, and what wasn't compared.
103
+ # Empty stale + uncomposed means every subgraph reached was accurate;
104
+ # `skipped` and `faked` say which weren't reached, and why.
105
+ def to_h
106
+ {
107
+ "stale" => @stale,
108
+ "uncomposed" => @uncomposed,
109
+ "skipped" => @skipped,
110
+ "faked" => @faked,
111
+ }
112
+ end
113
+
114
+ def report
115
+ return "#{@source} names no subgraphs" if @table.subgraphs.empty?
116
+
117
+ [headline, *section(STALE, @stale), *section(UNCOMPOSED, @uncomposed),
118
+ *skipped_section, *faked_section].join("\n")
119
+ end
120
+ alias to_s report
121
+
122
+ def inspect
123
+ "#<#{self.class.name} #{@stale.size} stale, #{@uncomposed.size} uncomposed, " \
124
+ "#{@checked.size}/#{@table.subgraphs.size} checked>"
125
+ end
126
+
127
+ private
128
+
129
+ STALE = "stale — the supergraph carries these, no schema here defines them (recompose):"
130
+ UNCOMPOSED = "not composed in — a schema here defines these, the supergraph doesn't carry them:"
131
+
132
+ # `subgraphs:` with string keys, refusing a name this supergraph
133
+ # doesn't have — the same check Testing::Subgraphs makes, and for the
134
+ # same reason: a typo'd key would silently check nothing
135
+ def named(given)
136
+ map = (given || {}).to_h { |name, schema| [name.to_s, schema] }
137
+ unknown = map.keys - @table.subgraphs
138
+ if unknown.any?
139
+ raise GraphWeaver::ConfigurationError, "subgraphs: names #{unknown.join(", ")}, which " \
140
+ "this supergraph doesn't have (its subgraphs are #{@table.subgraphs.join(", ")})"
141
+ end
142
+
143
+ map
144
+ end
145
+
146
+ def compare
147
+ @table.subgraphs.each do |name|
148
+ next unless (fitting = comparable(name))
149
+
150
+ @checked << name
151
+ record_stale(name, fitting)
152
+ record_uncomposed(name, fitting)
153
+ end
154
+ end
155
+
156
+ # The schemas to compare this subgraph against, or nil when there are
157
+ # none — recording why. A named schema is taken as given; otherwise
158
+ # the schemas defining every type the supergraph says it declares are
159
+ # the ones that could be it.
160
+ def comparable(name)
161
+ if @given.key?(name)
162
+ schema = @given[name]
163
+ # :fake, and anything else that isn't a schema class, has nothing
164
+ # real behind it
165
+ return [schema] if schema.is_a?(Class)
166
+
167
+ @faked << name
168
+ return
169
+ end
170
+
171
+ anchors = identifying_types(name)
172
+ fitting = anchors.empty? ? [] : @schemas.select { |s| anchors.all? { |t| s.get_type(t) } }
173
+ return fitting if fitting.any?
174
+
175
+ @skipped[name] = anchors
176
+ nil
177
+ end
178
+
179
+ def declared_types(name)
180
+ @table.types.select { |type| @table.declared_in(type).include?(name) }
181
+ end
182
+
183
+ # The types that recognize this subgraph's schema: the ones it
184
+ # declares, minus the roots every subgraph has.
185
+ def identifying_types(name) = declared_types(name) - ROOTS
186
+
187
+ # Fields the supergraph says this subgraph resolves, but none of its
188
+ # candidate schemas still defines. Every declared field, not only the
189
+ # explicitly routed ones — a field with no @join__field lives wherever
190
+ # its type does, and dropping one is exactly the drift this looks for.
191
+ def record_stale(name, fitting)
192
+ declared_types(name).each do |type_name|
193
+ @table.declared_fields(type_name).each do |field_name|
194
+ next unless @table.owners(type_name, field_name).include?(name)
195
+
196
+ coordinate = "#{type_name}.#{field_name}"
197
+ next if fitting.any? { |schema| GraphWeaver::Schemas.defines?(schema, coordinate) }
198
+
199
+ (@stale[coordinate] ||= []) << name
200
+ end
201
+ end
202
+ end
203
+
204
+ # fields those schemas define on this subgraph's types that the
205
+ # supergraph's own type doesn't carry
206
+ def record_uncomposed(name, fitting)
207
+ declared_types(name).each do |type_name|
208
+ fitting.each do |schema|
209
+ local_fields(schema, type_name).each do |field_name|
210
+ next if @table.declares?(type_name, field_name)
211
+
212
+ entry = (@uncomposed["#{type_name}.#{field_name}"] ||= [])
213
+ entry << schema.name unless entry.include?(schema.name)
214
+ end
215
+ end
216
+ end
217
+ end
218
+
219
+ # a schema's own fields on a type, minus federation's and
220
+ # introspection's plumbing (_entities, _service, __typename) — which
221
+ # no supergraph carries and which is never drift
222
+ def local_fields(schema, type_name)
223
+ type = schema.get_type(type_name)
224
+ members =
225
+ if type.respond_to?(:fields) then type.fields.keys
226
+ elsif type.respond_to?(:arguments) then type.arguments.keys
227
+ else []
228
+ end
229
+
230
+ members.reject { |field| field.start_with?("_") }
231
+ end
232
+
233
+ def headline
234
+ counts = [
235
+ ("#{@stale.size} stale" if @stale.any?),
236
+ ("#{@uncomposed.size} not composed in" if @uncomposed.any?),
237
+ ].compact
238
+ # "matches the schemas here" over nothing compared is the one verdict
239
+ # that reads as a pass and isn't one
240
+ verdict =
241
+ if counts.any? then counts.join(", ")
242
+ elsif vacuous? then "compared against nothing here"
243
+ else "matches the schemas here"
244
+ end
245
+ "#{@source}: #{verdict} " \
246
+ "(checked #{@checked.size} of #{@table.subgraphs.size} subgraphs)"
247
+ end
248
+
249
+ def section(title, entries)
250
+ return [] if entries.empty?
251
+
252
+ ["", title, *entries.sort.map { |coordinate, who| " #{coordinate} (#{who.join(", ")})" }]
253
+ end
254
+
255
+ # Not an error — a supergraph is routinely only partly local — but a
256
+ # clean report has to say what it didn't look at.
257
+ def skipped_section
258
+ return [] if @skipped.empty?
259
+
260
+ ["", "not checked — nothing here defines what the supergraph says these declare " \
261
+ "(running elsewhere, or the type is gone):",
262
+ *@skipped.sort.map { |name, types| " #{name} (#{types.empty? ? "root types only" : types.join(", ")})" }]
263
+ end
264
+
265
+ def faked_section
266
+ return [] if @faked.empty?
267
+
268
+ ["", "not checked — answered with fabricated data:", *@faked.sort.map { |name| " #{name}" }]
269
+ end
270
+ end
271
+ end
272
+ end
@@ -48,11 +48,19 @@ module GraphWeaver
48
48
  super
49
49
  end
50
50
 
51
+ # keeps #method and #respond_to? agreeing with method_missing — without
52
+ # it `struct.method(:nmae)` raises a bare NameError while `struct.nmae`
53
+ # gets the hint
54
+ def respond_to_missing?(name, include_private = false)
55
+ !!prop_hint(name.to_s) || super
56
+ end
57
+
51
58
  private
52
59
 
53
60
  def prop_hint(name)
54
61
  prop = GraphWeaver::Inflect.underscore(name)
55
- if prop != name && respond_to?(prop)
62
+ # method_defined?, not respond_to? respond_to_missing? lands back here
63
+ if prop != name && T.unsafe(self.class).method_defined?(prop)
56
64
  return "GraphQL fields generate snake_case props; use '#{prop}'"
57
65
  end
58
66
 
@@ -0,0 +1,90 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+
6
+ require_relative "errors"
7
+ require_relative "parsing"
8
+ require_relative "transport"
9
+
10
+ # Runs queries against a live graphql-ruby schema in the same process —
11
+ # no socket, no serialization:
12
+ #
13
+ # GraphWeaver.new(MySchema, context: { current_user: user })
14
+ # GraphWeaver::InProcess.new(MySchema, context: { current_user: user })
15
+ #
16
+ # A schema class already satisfies the client contract on its own (and
17
+ # still does — it stays usable bare). The wrapper adds the three things
18
+ # it can't do for itself:
19
+ #
20
+ # - **context:** — `Schema.execute` takes one, but nothing supplied it,
21
+ # so a resolver reading `context[:current_user]` got nil and it
22
+ # surfaced as "Cannot return null for non-nullable field Query.me".
23
+ # For server-side composition, context *is* the request.
24
+ # - **logging** — all of it lived in Transport#execute, which an
25
+ # in-process schema bypasses entirely.
26
+ # - **branded errors** — a resolver raise was a bare RuntimeError,
27
+ # where the same failure over HTTP is a ServerError, so
28
+ # `rescue GraphWeaver::Error` caught one and missed the other.
29
+ #
30
+ # The original exception stays as #cause: in-process, the real backtrace
31
+ # is usually the whole reason you're running in-process.
32
+ class GraphWeaver::InProcess
33
+ include GraphWeaver::Parsing
34
+
35
+ # the schema queries run against, and the context handed to every one
36
+ attr_reader :schema, :context
37
+
38
+ def initialize(schema, context: {})
39
+ unless schema.respond_to?(:execute)
40
+ raise ArgumentError, "expected a graphql-ruby schema class, got #{schema.inspect}"
41
+ end
42
+
43
+ @schema = schema
44
+ @context = context
45
+ end
46
+
47
+ def execute(query, variables: {}, operation_name: nil)
48
+ operation_name ||= GraphWeaver::Transport.operation_name(query)
49
+ payload = { url: nil, schema: @schema.to_s, operation: operation_name }
50
+
51
+ GraphWeaver.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
52
+ perform(query, variables, operation_name, payload)
53
+ end
54
+ end
55
+
56
+ # The query itself. Separate from execute so the instrumenter wraps a
57
+ # call rather than a block this method returns out of.
58
+ private def perform(query, variables, operation_name, payload)
59
+ # same tag/truncation as the network transports, so one log reads the
60
+ # same whichever side of the seam a query ran on
61
+ tag = GraphWeaver.logger && GraphWeaver::Transport.log_tag(operation_name)
62
+
63
+ GraphWeaver.log(:debug) do
64
+ "in-process #{@schema} #{tag} variables=#{JSON.generate(variables)}\n" \
65
+ "#{GraphWeaver::Transport.truncate_for_log(query)}"
66
+ end
67
+
68
+ result = GraphWeaver.log_timed(:debug, "in-process #{@schema} #{tag} completed") do
69
+ @schema.execute(query, variables:, operation_name:, context: @context)
70
+ end
71
+
72
+ # the same key the network transports set, so one instrumenter
73
+ # subscriber reads both sides of the seam without branching — a
74
+ # resolver raise rides the ServerError(500) the hook already sees
75
+ payload[:status] = 200
76
+ result
77
+ rescue GraphWeaver::Error
78
+ raise
79
+ rescue => e
80
+ # a resolver blew up. The same failure over HTTP arrives as a 500, so
81
+ # raise what HTTP would — code that rescues GraphWeaver::Error, or
82
+ # branches on ServerError#status, behaves the same either side.
83
+ raise GraphWeaver::ServerError.new(status: 500, body: "#{e.class}: #{e.message}")
84
+ end
85
+
86
+ # never leak the context (session tokens, current_user) through logs or
87
+ # exceptions — an in-process client inspects as its schema, nothing more
88
+ def inspect = "#<#{self.class.name} schema=#{@schema}>"
89
+ alias to_s inspect
90
+ end
@@ -27,12 +27,24 @@ module GraphWeaver
27
27
 
28
28
  # the wire hash — optional fields left nil stay off the wire
29
29
  def serialize
30
- self.class.const_get(:FIELDS).each_with_object({}) do |field, wire|
30
+ wire = self.class.const_get(:FIELDS).each_with_object({}) do |field, out|
31
31
  value = public_send(field.prop)
32
32
  next if value.nil? && !field.required
33
33
 
34
- wire[field.wire] = field.serializer && !value.nil? ? field.serializer.call(value) : value
34
+ out[field.wire] = field.serializer && !value.nil? ? field.serializer.call(value) : value
35
35
  end
36
+
37
+ # @oneOf declares "exactly one of these", but every field is nullable, so
38
+ # nothing before here can enforce it — not the struct's types, not the
39
+ # server until the round trip
40
+ if wire.size != 1 && self.class.const_defined?(:ONE_OF, false)
41
+ raise GraphWeaver::InputError.new(
42
+ "#{self.class} is @oneOf — supply exactly one field, got #{wire.empty? ? "none" : wire.keys.sort.join(", ")}",
43
+ struct: self.class,
44
+ )
45
+ end
46
+
47
+ wire
36
48
  end
37
49
  alias_method :to_h, :serialize
38
50
 
@@ -37,5 +37,34 @@ module GraphWeaver
37
37
  log(level) { "#{label} (#{ms}ms)" }
38
38
  result
39
39
  end
40
+
41
+ # One callable wrapping every request GraphWeaver makes — over the
42
+ # wire or in-process — so an APM can time it and count errors. A
43
+ # no-op until you set one:
44
+ #
45
+ # GraphWeaver.instrumenter = lambda do |event, payload, &block|
46
+ # ActiveSupport::Notifications.instrument(event, payload, &block)
47
+ # end
48
+ #
49
+ # It must call the block and return its value. The only event today
50
+ # is EXECUTE_EVENT; its payload carries :url (nil in-process),
51
+ # :schema (in-process only), :operation (the document's operation
52
+ # name, nil for an anonymous one), and — added after the response
53
+ # lands — :status. Never the query text or the variables: those
54
+ # carry PII and belong at debug on the logger, where they're gated.
55
+ attr_accessor :instrumenter
56
+
57
+ # Internal: wrap the block in the instrumenter, if one is set. The
58
+ # payload is a plain Hash the caller may add to inside the block.
59
+ def instrument(event, payload)
60
+ hook = instrumenter
61
+ return yield unless hook
62
+
63
+ hook.call(event, payload) { yield }
64
+ end
40
65
  end
66
+
67
+ # The one instrumentation event: a single GraphQL request, start to
68
+ # parsed response, whichever client slot served it.
69
+ EXECUTE_EVENT = "graph_weaver.execute"
41
70
  end