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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1314 -0
- data/CLAUDE.md +100 -8
- data/DECISIONS.md +309 -0
- data/Gemfile.lock +23 -23
- data/NOTES.md +5 -5
- data/PLAN.md +106 -135
- data/README.md +115 -96
- data/REVIEW.md +946 -0
- data/docs/cassettes.md +75 -48
- data/docs/editors.md +82 -0
- data/docs/errors.md +32 -30
- data/docs/federation.md +520 -48
- data/docs/generated_modules.md +352 -137
- data/docs/getting_started.md +237 -67
- data/docs/logging.md +35 -6
- data/docs/real_world.md +21 -15
- data/docs/scalars.md +49 -154
- data/docs/testing.md +299 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +112 -0
- data/graph_weaver.gemspec +3 -1
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +114 -111
- data/lib/graph_weaver/codegen/aliases.rb +217 -0
- data/lib/graph_weaver/codegen/emit.rb +272 -258
- data/lib/graph_weaver/codegen/enum_type.rb +27 -124
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +593 -334
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +9 -1
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +14 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +67 -0
- data/lib/graph_weaver/query_module.rb +55 -0
- data/lib/graph_weaver/railtie.rb +23 -1
- data/lib/graph_weaver/representation.rb +74 -0
- data/lib/graph_weaver/response.rb +7 -0
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +794 -59
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +43 -8
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +160 -61
- data/lib/graph_weaver/testing/coverage.rb +165 -0
- data/lib/graph_weaver/testing/failure.rb +10 -23
- data/lib/graph_weaver/testing/fake_client.rb +181 -21
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1431 -0
- data/lib/graph_weaver/testing/subgraphs.rb +130 -0
- data/lib/graph_weaver/testing.rb +204 -14
- data/lib/graph_weaver/transport/faraday.rb +28 -10
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +67 -14
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +389 -170
- metadata +20 -3
data/lib/graph_weaver/errors.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
369
|
-
drift =
|
|
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(
|
|
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
|
data/lib/graph_weaver/hints.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
data/lib/graph_weaver/logging.rb
CHANGED
|
@@ -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
|