graph_weaver 0.4.6 → 0.5.1

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1442 -0
  3. data/Gemfile.lock +23 -23
  4. data/README.md +115 -96
  5. data/docs/cassettes.md +93 -46
  6. data/docs/editors.md +82 -0
  7. data/docs/errors.md +34 -30
  8. data/docs/federation.md +521 -48
  9. data/docs/generated_modules.md +352 -137
  10. data/docs/getting_started.md +237 -67
  11. data/docs/logging.md +35 -6
  12. data/docs/real_world.md +21 -15
  13. data/docs/scalars.md +49 -154
  14. data/docs/testing.md +300 -52
  15. data/docs/transports.md +129 -30
  16. data/docs/upgrading.md +134 -0
  17. data/graph_weaver.gemspec +19 -3
  18. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  19. data/lib/graph_weaver/client.rb +118 -111
  20. data/lib/graph_weaver/codegen/aliases.rb +223 -0
  21. data/lib/graph_weaver/codegen/emit.rb +283 -261
  22. data/lib/graph_weaver/codegen/enum_type.rb +25 -124
  23. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  24. data/lib/graph_weaver/codegen/scalar_type.rb +69 -66
  25. data/lib/graph_weaver/codegen/type_helpers.rb +140 -0
  26. data/lib/graph_weaver/codegen.rb +672 -336
  27. data/lib/graph_weaver/errors.rb +154 -16
  28. data/lib/graph_weaver/federation.rb +259 -0
  29. data/lib/graph_weaver/hints.rb +9 -1
  30. data/lib/graph_weaver/in_process.rb +90 -0
  31. data/lib/graph_weaver/input_struct.rb +14 -2
  32. data/lib/graph_weaver/logging.rb +29 -0
  33. data/lib/graph_weaver/parsing.rb +59 -0
  34. data/lib/graph_weaver/query_module.rb +55 -0
  35. data/lib/graph_weaver/railtie.rb +23 -1
  36. data/lib/graph_weaver/representation.rb +74 -0
  37. data/lib/graph_weaver/response.rb +7 -0
  38. data/lib/graph_weaver/retry.rb +29 -8
  39. data/lib/graph_weaver/rspec.rb +220 -16
  40. data/lib/graph_weaver/schema_loader.rb +819 -60
  41. data/lib/graph_weaver/schemas.rb +48 -0
  42. data/lib/graph_weaver/selection.rb +43 -8
  43. data/lib/graph_weaver/tasks.rb +220 -22
  44. data/lib/graph_weaver/testing/cassette.rb +249 -81
  45. data/lib/graph_weaver/testing/coverage.rb +160 -0
  46. data/lib/graph_weaver/testing/failure.rb +14 -25
  47. data/lib/graph_weaver/testing/fake_client.rb +182 -22
  48. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  49. data/lib/graph_weaver/testing/router.rb +1452 -0
  50. data/lib/graph_weaver/testing/subgraphs.rb +134 -0
  51. data/lib/graph_weaver/testing.rb +209 -13
  52. data/lib/graph_weaver/transport/faraday.rb +28 -10
  53. data/lib/graph_weaver/transport/http.rb +99 -36
  54. data/lib/graph_weaver/transport.rb +67 -14
  55. data/lib/graph_weaver/version.rb +1 -1
  56. data/lib/graph_weaver.rb +416 -172
  57. metadata +25 -9
  58. data/CLAUDE.md +0 -69
  59. data/Makefile +0 -23
  60. data/NOTES.md +0 -182
  61. data/PLAN.md +0 -144
@@ -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
 
@@ -154,12 +189,32 @@ module GraphWeaver
154
189
  extensions["code"] || @error_type
155
190
  end
156
191
 
157
- # Message shapes servers use when they reject the *shape* of a query
158
- # (unknown field/type/argument). Heuristic by necessity: only Apollo
159
- # sets a standard code (GRAPHQL_VALIDATION_FAILED); graphql-ruby and
160
- # GitHub speak in messages.
192
+ # Codes a server sets when it rejects the *shape* of a query. Apollo has
193
+ # one flat code; graphql-ruby names the rule that fired, and it is the
194
+ # in-process client this library ships, so its drift-shaped rules are
195
+ # listed rather than guessed at from prose.
196
+ VALIDATION_CODES = T.let(%w[
197
+ GRAPHQL_VALIDATION_FAILED
198
+ undefinedField undefinedType undefinedDirective
199
+ argumentNotAccepted argumentType argumentLiteralsIncompatible
200
+ missingRequiredArguments missingRequiredInputObjectAttribute
201
+ cannotSpreadFragment fragmentOnNonCompositeType
202
+ variableMismatch variableRequiresValidType variableNotDefined
203
+ selectionMismatch invalidOneOfInputObject
204
+ ].to_set.freeze, T::Set[String])
205
+
206
+ # For servers that send no code at all. Variable coercion reports through
207
+ # the message in both dialects, and a required input field appearing is
208
+ # unambiguous here: a generated input struct enforces its own required
209
+ # fields, so the app cannot produce that error itself.
161
210
  VALIDATION_MESSAGE = T.let(
162
- /doesn't exist|Cannot query field|Unknown (field|type|argument)|isn't defined|undefined (field|type)/i,
211
+ Regexp.union(
212
+ /doesn't exist/i, /Cannot query field/i, /Unknown (field|type|argument)/i,
213
+ /is ?n[o']t defined/i, /undefined (field|type)/i, /No such type/i,
214
+ /can't be spread inside/i, /is missing required arguments/i,
215
+ /doesn't accept argument/i, /Field is not defined on/i,
216
+ /was provided invalid value for .+ \(Expected value to not be null\)/i,
217
+ ),
163
218
  Regexp,
164
219
  )
165
220
 
@@ -168,7 +223,25 @@ module GraphWeaver
168
223
  # changed after generation.
169
224
  sig { returns(T::Boolean) }
170
225
  def validation?
171
- code == "GRAPHQL_VALIDATION_FAILED" || VALIDATION_MESSAGE.match?(message)
226
+ # to_s: a nil code is never a member, and sorbet can't narrow a call
227
+ VALIDATION_CODES.include?(code.to_s) || VALIDATION_MESSAGE.match?(message)
228
+ end
229
+
230
+ # The codes servers use to say "you're going too fast". No standard
231
+ # exists, so this is the union of what the big graphs actually send:
232
+ # Shopify THROTTLED, GitHub RATE_LIMITED, Apollo/Hasura the rest.
233
+ # Pass it to Retry (retry_codes:) rather than hand-writing strings.
234
+ THROTTLE_CODES = T.let(
235
+ %w[THROTTLED RATE_LIMITED RATE_LIMIT_EXCEEDED TOO_MANY_REQUESTS REQUEST_LIMIT_EXCEEDED].freeze,
236
+ T::Array[String],
237
+ )
238
+
239
+ # True when this error is the GraphQL-level equivalent of a 429 —
240
+ # the same question ServerError#throttled? asks of an HTTP status,
241
+ # since an API may answer either way.
242
+ sig { returns(T::Boolean) }
243
+ def throttled?
244
+ THROTTLE_CODES.include?(code)
172
245
  end
173
246
 
174
247
  # The field the error points at, as a stable dotted path with list
@@ -249,6 +322,15 @@ module GraphWeaver
249
322
  errors.any?(&:validation?)
250
323
  end
251
324
 
325
+ # True when the server said "you're going too fast" in the errors
326
+ # array rather than in an HTTP status — back off and retry, don't
327
+ # rewrite the query. Same name as ServerError#throttled?, because an
328
+ # API may answer either way and callers shouldn't have to care which.
329
+ sig { returns(T::Boolean) }
330
+ def throttled?
331
+ errors.any?(&:throttled?)
332
+ end
333
+
252
334
  # Errors grouped by the field they point at (index-stripped dotted
253
335
  # path; nil key for global errors) — iterate with each_error:
254
336
  #
@@ -312,7 +394,7 @@ module GraphWeaver
312
394
  end
313
395
 
314
396
  # Raised when a GraphQL response carried top-level errors and the caller
315
- # demanded data (Response#data!, or the one-shot GraphWeaver.execute).
397
+ # demanded data (Response#data!, or the one-shot GraphWeaver.run!).
316
398
  # Carries the structured errors, any partial data, and top-level
317
399
  # extensions (cost/throttle metadata).
318
400
  class QueryError < Error
@@ -354,19 +436,28 @@ module GraphWeaver
354
436
  def to_h
355
437
  super.merge(
356
438
  "schema_stale" => schema_stale?,
439
+ "throttled" => throttled?,
357
440
  "codes" => codes,
358
441
  "errors" => errors.map(&:to_h),
359
442
  "extensions" => extensions,
360
443
  )
361
444
  end
362
445
 
446
+ # what a validation-shaped rejection means, and the way out of it
447
+ DRIFT_HINT = T.let(
448
+ "the server rejected the query shape: the schema may have changed since generation; " \
449
+ "refresh the schema dump and regenerate " \
450
+ "(rake graph_weaver:schema:refresh && rake graph_weaver:generate)",
451
+ String,
452
+ )
453
+
363
454
  private
364
455
 
365
456
  sig { returns(String) }
366
457
  def summary
367
458
  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)" : ""
459
+ more = " (and #{errors.size - 1} more)" if errors.size > 1
460
+ drift = " — #{DRIFT_HINT}" if schema_stale?
370
461
  "GraphQL query failed: #{first}#{more}#{drift}"
371
462
  end
372
463
  end
@@ -383,10 +474,16 @@ module GraphWeaver
383
474
  sig { returns(T.untyped) }
384
475
  attr_reader :struct
385
476
 
477
+ # sorbet-runtime appends its own frame to a prop type error ("Caller:
478
+ # .../call_validation.rb:331"), which is a path into the gem and never
479
+ # into the code with the problem — so it is dropped rather than reprinted
480
+ # as if it located anything.
481
+ SORBET_CALLER = /\s*\nCaller: .*\z/m
482
+
386
483
  sig { params(struct: T.untyped, error: T.nilable(Exception), message: T.nilable(String)).void }
387
484
  def initialize(struct:, error: nil, message: nil)
388
485
  @struct = struct
389
- super("failed to cast response into #{struct}: #{message || error&.message}")
486
+ super("failed to cast response into #{struct}: #{message || error&.message&.sub(SORBET_CALLER, "")}")
390
487
  end
391
488
 
392
489
  sig { override.returns(T::Hash[String, T.untyped]) }
@@ -424,6 +521,15 @@ module GraphWeaver
424
521
  end
425
522
  end
426
523
 
524
+ # The setup doesn't add up — judged against your schema, not against the
525
+ # shape of an argument. Which Ruby schema serves which subgraph is the
526
+ # case that exists: two schemas fit one subgraph, or the one you named
527
+ # doesn't define what the supergraph says that subgraph resolves. A
528
+ # verdict the library reached, so it's under the Error umbrella and a
529
+ # spec helper can rescue it; a plainly wrong argument (`pool_size: must
530
+ # be >= 1`) stays an ArgumentError, as in any Ruby method.
531
+ class ConfigurationError < Error; end
532
+
427
533
  # Build-time: the query didn't validate against the schema. Carries the
428
534
  # structured validation errors (message + line/column) rather than a
429
535
  # joined string. Under the Error umbrella like everything else raised
@@ -437,12 +543,44 @@ module GraphWeaver
437
543
  sig { params(errors: T::Array[T::Hash[Symbol, T.untyped]]).void }
438
544
  def initialize(errors)
439
545
  @errors = errors
440
- super("invalid query: #{errors.map { |e| e[:message] }.join("; ")}")
546
+ super(render(errors))
441
547
  end
442
548
 
443
549
  sig { override.returns(T::Hash[String, T.untyped]) }
444
550
  def to_h
445
551
  super.merge("errors" => errors.map { |e| e.transform_keys(&:to_s) })
446
552
  end
553
+
554
+ # "queries/person.graphql:4:5 Field 'nmae' …" back into its three parts —
555
+ # [path, "line:column", message]. Codegen folds the position (and, when it
556
+ # knows it, the file) into :message, so anything reporting the parts
557
+ # separately splits it back out here rather than growing a second splitter
558
+ # to disagree with. A message with no such prefix passes through whole.
559
+ sig { params(error: T::Hash[Symbol, T.untyped]).returns([T.nilable(String), String, String]) }
560
+ def self.split(error)
561
+ message = error[:message].to_s
562
+ position = [error[:line], error[:column]].compact.join(":")
563
+ return [nil, position, message] if position.empty?
564
+
565
+ match = message.match(/\A(?:(?<path>.+):)?#{Regexp.escape(position)} (?<rest>.*)\z/m)
566
+ match ? [match[:path], position, match[:rest]] : [nil, position, message]
567
+ end
568
+
569
+ private
570
+
571
+ # Compiler-style: the query file once in the header, then one error per
572
+ # line — thirty typos on one joined line is a wall nobody reads.
573
+ sig { params(errors: T::Array[T::Hash[Symbol, T.untyped]]).returns(String) }
574
+ def render(errors)
575
+ entries = errors.map { |error| ValidationError.split(error) }
576
+ paths = entries.map(&:first).compact.uniq
577
+ hoisted = paths.one?
578
+
579
+ lines = entries.map do |path, position, message|
580
+ prefix = [(path unless hoisted), position].reject { |part| part.nil? || part.empty? }.join(":")
581
+ prefix.empty? ? " #{message}" : " #{prefix} #{message}"
582
+ end
583
+ [hoisted ? "invalid query in #{paths.first}:" : "invalid query:", *lines].join("\n")
584
+ end
447
585
  end
448
586
  end
@@ -0,0 +1,259 @@
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 — asked the way the
82
+ # loader asks it, which a one-line supergraph doesn't fool
83
+ @source = GraphWeaver::SchemaLoader.sdl_content?(source) ? "the supergraph" : source
84
+ @given = @table.named_subgraphs(subgraphs)
85
+ @schemas = schemas || GraphWeaver::Schemas.loaded
86
+ @stale = {}
87
+ @uncomposed = {}
88
+ @skipped = {}
89
+ @faked = []
90
+ @checked = []
91
+ compare
92
+ end
93
+
94
+ # whether the supergraph and the code here disagree — what CI gates on
95
+ def drift? = @stale.any? || @uncomposed.any?
96
+
97
+ # Nothing was compared, so "no drift" is vacuous: the gate would pass
98
+ # whatever the subgraphs said. Categorically different from "checked 3
99
+ # of 4" — that one checked something, and a partly-local supergraph is
100
+ # a supported setup.
101
+ def vacuous? = @checked.empty?
102
+
103
+ # JSON-ready: the drift, keyed by coordinate, and what wasn't compared.
104
+ # Empty stale + uncomposed means every subgraph reached was accurate;
105
+ # `skipped` and `faked` say which weren't reached, and why.
106
+ def to_h
107
+ {
108
+ "stale" => @stale,
109
+ "uncomposed" => @uncomposed,
110
+ "skipped" => @skipped,
111
+ "faked" => @faked,
112
+ }
113
+ end
114
+
115
+ def report
116
+ return "#{@source} names no subgraphs" if @table.subgraphs.empty?
117
+
118
+ [headline, *section(STALE, @stale), *section(UNCOMPOSED, @uncomposed),
119
+ *skipped_section, *faked_section].join("\n")
120
+ end
121
+ alias to_s report
122
+
123
+ def inspect
124
+ "#<#{self.class.name} #{@stale.size} stale, #{@uncomposed.size} uncomposed, " \
125
+ "#{@checked.size}/#{@table.subgraphs.size} checked>"
126
+ end
127
+
128
+ private
129
+
130
+ STALE = "stale — the supergraph carries these, no schema here defines them (recompose):"
131
+ UNCOMPOSED = "not composed in — a schema here defines these, the supergraph doesn't carry them:"
132
+
133
+ def compare
134
+ @table.subgraphs.each do |name|
135
+ next unless (fitting = comparable(name))
136
+
137
+ @checked << name
138
+ record_stale(name, fitting)
139
+ record_uncomposed(name, fitting)
140
+ end
141
+ end
142
+
143
+ # The schemas to compare this subgraph against, or nil when there are
144
+ # none — recording why. A named schema is taken as given; otherwise
145
+ # the schemas defining every type the supergraph says it declares are
146
+ # the ones that could be it.
147
+ def comparable(name)
148
+ if @given.key?(name)
149
+ schema = @given[name]
150
+ # :fake, and anything else that isn't a schema class, has nothing
151
+ # real behind it
152
+ return [schema] if schema.is_a?(Class)
153
+
154
+ @faked << name
155
+ return
156
+ end
157
+
158
+ anchors = identifying_types(name)
159
+ fitting = anchors.empty? ? [] : @schemas.select { |s| anchors.all? { |t| s.get_type(t) } }
160
+ return fitting if fitting.any?
161
+
162
+ @skipped[name] = anchors
163
+ nil
164
+ end
165
+
166
+ def declared_types(name)
167
+ @table.types.select { |type| @table.declared_in(type).include?(name) }
168
+ end
169
+
170
+ # The types that recognize this subgraph's schema: the ones it
171
+ # declares, minus the roots every subgraph has.
172
+ def identifying_types(name) = declared_types(name) - ROOTS
173
+
174
+ # Fields the supergraph says this subgraph resolves, but none of its
175
+ # candidate schemas still defines. Every declared field, not only the
176
+ # explicitly routed ones — a field with no @join__field lives wherever
177
+ # its type does, and dropping one is exactly the drift this looks for.
178
+ def record_stale(name, fitting)
179
+ declared_types(name).each do |type_name|
180
+ @table.declared_fields(type_name).each do |field_name|
181
+ next unless @table.owners(type_name, field_name).include?(name)
182
+
183
+ coordinate = "#{type_name}.#{field_name}"
184
+ next if fitting.any? { |schema| GraphWeaver::Schemas.defines?(schema, coordinate) }
185
+
186
+ (@stale[coordinate] ||= []) << name
187
+ end
188
+ end
189
+ end
190
+
191
+ # fields those schemas define on this subgraph's types that the
192
+ # supergraph's own type doesn't carry
193
+ def record_uncomposed(name, fitting)
194
+ declared_types(name).each do |type_name|
195
+ fitting.each do |schema|
196
+ local_fields(schema, type_name).each do |field_name|
197
+ next if @table.declares?(type_name, field_name)
198
+
199
+ entry = (@uncomposed["#{type_name}.#{field_name}"] ||= [])
200
+ entry << schema.name unless entry.include?(schema.name)
201
+ end
202
+ end
203
+ end
204
+ end
205
+
206
+ # a schema's own fields on a type, minus federation's and
207
+ # introspection's plumbing (_entities, _service, __typename) — which
208
+ # no supergraph carries and which is never drift
209
+ def local_fields(schema, type_name)
210
+ type = schema.get_type(type_name)
211
+ members =
212
+ if type.respond_to?(:fields) then type.fields.keys
213
+ elsif type.respond_to?(:arguments) then type.arguments.keys
214
+ else []
215
+ end
216
+
217
+ members.reject { |field| field.start_with?("_") }
218
+ end
219
+
220
+ def headline
221
+ counts = [
222
+ ("#{@stale.size} stale" if @stale.any?),
223
+ ("#{@uncomposed.size} not composed in" if @uncomposed.any?),
224
+ ].compact
225
+ # "matches the schemas here" over nothing compared is the one verdict
226
+ # that reads as a pass and isn't one
227
+ verdict =
228
+ if counts.any? then counts.join(", ")
229
+ elsif vacuous? then "compared against nothing here"
230
+ else "matches the schemas here"
231
+ end
232
+ "#{@source}: #{verdict} " \
233
+ "(checked #{@checked.size} of #{@table.subgraphs.size} subgraphs)"
234
+ end
235
+
236
+ def section(title, entries)
237
+ return [] if entries.empty?
238
+
239
+ ["", title, *entries.sort.map { |coordinate, who| " #{coordinate} (#{who.join(", ")})" }]
240
+ end
241
+
242
+ # Not an error — a supergraph is routinely only partly local — but a
243
+ # clean report has to say what it didn't look at.
244
+ def skipped_section
245
+ return [] if @skipped.empty?
246
+
247
+ ["", "not checked — nothing here defines what the supergraph says these declare " \
248
+ "(running elsewhere, or the type is gone):",
249
+ *@skipped.sort.map { |name, types| " #{name} (#{types.empty? ? "root types only" : types.join(", ")})" }]
250
+ end
251
+
252
+ def faked_section
253
+ return [] if @faked.empty?
254
+
255
+ ["", "not checked — answered with fabricated data:", *@faked.sort.map { |name| " #{name}" }]
256
+ end
257
+ end
258
+ end
259
+ 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