graph_weaver 0.5.0 → 0.6.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 +537 -0
- data/Gemfile.lock +19 -19
- data/README.md +74 -53
- data/docs/cassettes.md +29 -4
- data/docs/editors.md +3 -1
- data/docs/errors.md +75 -16
- data/docs/federation.md +206 -155
- data/docs/generated_modules.md +223 -166
- data/docs/getting_started.md +106 -82
- data/docs/logging.md +35 -5
- data/docs/scalars.md +119 -24
- data/docs/testing.md +196 -155
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +243 -22
- data/graph_weaver.gemspec +16 -2
- data/lib/generators/graph_weaver/install_generator.rb +31 -16
- data/lib/graph_weaver/client.rb +52 -15
- data/lib/graph_weaver/codegen/aliases.rb +15 -8
- data/lib/graph_weaver/codegen/emit.rb +107 -42
- data/lib/graph_weaver/codegen/enum_type.rb +4 -3
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
- data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
- data/lib/graph_weaver/codegen.rb +382 -105
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +57 -13
- data/lib/graph_weaver/federation.rb +10 -22
- data/lib/graph_weaver/hints.rb +76 -2
- data/lib/graph_weaver/in_process.rb +11 -8
- data/lib/graph_weaver/inflect.rb +2 -0
- data/lib/graph_weaver/input_struct.rb +115 -12
- data/lib/graph_weaver/internal/overrides.rb +101 -0
- data/lib/graph_weaver/internal/planner.rb +868 -0
- data/lib/graph_weaver/internal/schemas.rb +50 -0
- data/lib/graph_weaver/internal/selection.rb +127 -0
- data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
- data/lib/graph_weaver/internal/values.rb +181 -0
- data/lib/graph_weaver/internal.rb +206 -0
- data/lib/graph_weaver/logging.rb +108 -20
- data/lib/graph_weaver/parsing.rb +6 -13
- data/lib/graph_weaver/query_module.rb +2 -0
- data/lib/graph_weaver/railtie.rb +113 -14
- data/lib/graph_weaver/representation.rb +30 -2
- data/lib/graph_weaver/response.rb +15 -0
- data/lib/graph_weaver/retry.rb +54 -22
- data/lib/graph_weaver/rspec.rb +63 -18
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +126 -35
- data/lib/graph_weaver/tasks.rb +88 -36
- data/lib/graph_weaver/testing/cassette.rb +131 -78
- data/lib/graph_weaver/testing/coverage.rb +11 -15
- data/lib/graph_weaver/testing/failure.rb +14 -8
- data/lib/graph_weaver/testing/fake_client.rb +253 -60
- data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
- data/lib/graph_weaver/testing/router.rb +147 -840
- data/lib/graph_weaver/testing.rb +40 -83
- data/lib/graph_weaver/transport/faraday.rb +1 -1
- data/lib/graph_weaver/transport/http.rb +29 -12
- data/lib/graph_weaver/transport.rb +11 -34
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +221 -118
- metadata +17 -13
- data/CLAUDE.md +0 -161
- data/DECISIONS.md +0 -309
- data/Makefile +0 -23
- data/NOTES.md +0 -182
- data/PLAN.md +0 -115
- data/REVIEW.md +0 -946
- data/lib/graph_weaver/schemas.rb +0 -46
- data/lib/graph_weaver/selection.rb +0 -120
- data/lib/graph_weaver/testing/values.rb +0 -98
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
module GraphWeaver
|
|
7
|
+
# Called by generated code — not semver'd for direct use.
|
|
8
|
+
#
|
|
9
|
+
# Turning a loose value into the Ruby type a generated kwarg promises.
|
|
10
|
+
#
|
|
11
|
+
# Generated `execute` sigs are `.checked(:never)`, so a `params[:first]`
|
|
12
|
+
# String reaches the body instead of being rejected by sorbet-runtime
|
|
13
|
+
# first — and this is what stands where that check used to: it converts
|
|
14
|
+
# what converts unambiguously and refuses the rest.
|
|
15
|
+
#
|
|
16
|
+
# The conversions raise plain Ruby errors (as Kernel#Integer does) and the
|
|
17
|
+
# caller brands them: `.variable` for an execute kwarg, InputStruct.coerce
|
|
18
|
+
# for an input-object field, Hints.field for a response leaf. One table in
|
|
19
|
+
# both directions — `Coerce.float` is also Float's cast from the wire.
|
|
20
|
+
module Coerce
|
|
21
|
+
# Kernel#Integer reads "010" as octal and "0x1f" as hex, and Kernel#Float
|
|
22
|
+
# takes "1_0" — Ruby literal syntax, not wire syntax, and a zero-padded
|
|
23
|
+
# form field is a real input that must not silently mean something else.
|
|
24
|
+
INTEGER = /\A[+-]?\d+\z/
|
|
25
|
+
NUMBER = /\A[+-]?\d+(\.\d+)?([eE][+-]?\d+)?\z/
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def integer(value)
|
|
29
|
+
case value
|
|
30
|
+
when Integer then value
|
|
31
|
+
when Float then whole(value)
|
|
32
|
+
when String then INTEGER.match?(value.strip) ? Integer(value.strip, 10) : unparseable(value, "Int")
|
|
33
|
+
else refuse(value, "Int")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def float(value)
|
|
38
|
+
case value
|
|
39
|
+
when Float then value
|
|
40
|
+
when Integer then value.to_f
|
|
41
|
+
when String then NUMBER.match?(value.strip) ? Float(value.strip) : unparseable(value, "Float")
|
|
42
|
+
else refuse(value, "Float")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Ruby has no Kernel#Boolean, and every string rule ("0", "off", "no")
|
|
47
|
+
# is somebody's convention — so refuse rather than pick one.
|
|
48
|
+
def boolean(value)
|
|
49
|
+
return value if value == true || value == false
|
|
50
|
+
|
|
51
|
+
refuse(value, "Boolean", "there is no one right reading of it — convert at the call site")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def string(value)
|
|
55
|
+
return value if value.is_a?(String)
|
|
56
|
+
|
|
57
|
+
refuse(value, "String")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The GraphQL spec has ID serialize as a String but accept an integer
|
|
61
|
+
# input, which is `execute(id: user.id)` — the everyday Rails call.
|
|
62
|
+
# String gets no such licence: an Integer where a String belongs is
|
|
63
|
+
# more often a bug than a spelling.
|
|
64
|
+
def id(value)
|
|
65
|
+
case value
|
|
66
|
+
when String then value
|
|
67
|
+
when Integer then value.to_s
|
|
68
|
+
else refuse(value, "ID")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Brands one variable's coercion failure with the variable and the
|
|
73
|
+
# operation: a cast complains about the value alone ("invalid date"),
|
|
74
|
+
# which locates nothing in an app that runs a hundred queries.
|
|
75
|
+
def variable(name, operation, value)
|
|
76
|
+
yield value
|
|
77
|
+
rescue GraphWeaver::InputError => e
|
|
78
|
+
raise GraphWeaver::InputError.new(
|
|
79
|
+
"#{at(name, operation)}: #{Internal::Redact.detail(name, e.message)}", field: name, struct: e.struct,
|
|
80
|
+
)
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
# a cast complains about the value without quoting it ("invalid date")
|
|
83
|
+
shown = value.inspect
|
|
84
|
+
got = " (got #{shown})" unless e.message.include?(shown)
|
|
85
|
+
raise GraphWeaver::InputError.new(
|
|
86
|
+
"#{at(name, operation)}: #{Internal::Redact.detail(name, "#{e.message}#{got}")}", field: name,
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def at(name, operation) = operation ? "$#{name} of #{operation}" : "$#{name}"
|
|
93
|
+
|
|
94
|
+
def whole(value)
|
|
95
|
+
# Integer(2.5) is 2 — a silent loss where refusing costs nothing
|
|
96
|
+
return value.to_i if value.finite? && (value % 1).zero?
|
|
97
|
+
|
|
98
|
+
raise ArgumentError, "#{expected("Int")}, got #{value.inspect} — not a whole number"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def unparseable(value, scalar)
|
|
102
|
+
raise ArgumentError, "#{expected(scalar)}, got #{value.inspect}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# ::TypeError — inside GraphWeaver, a bare TypeError is ours
|
|
106
|
+
def refuse(value, scalar, hint = nil)
|
|
107
|
+
raise ::TypeError, "#{expected(scalar)}, got #{value.inspect}#{" — #{hint}" if hint}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def expected(scalar) = "expected #{%w[Int ID].include?(scalar) ? "an" : "a"} #{scalar}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
data/lib/graph_weaver/errors.rb
CHANGED
|
@@ -12,7 +12,7 @@ module GraphWeaver
|
|
|
12
12
|
# all. #message is the human-friendly side; #to_h is the machine side —
|
|
13
13
|
# a JSON-ready Hash (string keys) for logging, agents, or surfacing
|
|
14
14
|
# structured failures to users. One subclass per failure site —
|
|
15
|
-
# {TransportError} (
|
|
15
|
+
# {TransportError} (no response came back), {ServerError} (non-2xx),
|
|
16
16
|
# {QueryError} (GraphQL-level errors), {TypeError} (response wouldn't
|
|
17
17
|
# cast), {InputError} (bad variables), {ValidationError} (build time),
|
|
18
18
|
# {ConfigurationError} (setup judged against your schema) — each merging
|
|
@@ -25,7 +25,7 @@ module GraphWeaver
|
|
|
25
25
|
sig { params(args: T.untyped).void }
|
|
26
26
|
def initialize(*args)
|
|
27
27
|
super
|
|
28
|
-
GraphWeaver.log(:warn) { "#{self.class.name}: #{message}" }
|
|
28
|
+
GraphWeaver::Internal::Log.log(:warn) { "#{self.class.name}: #{message}" }
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
sig { overridable.returns(T::Hash[String, T.untyped]) }
|
|
@@ -34,9 +34,11 @@ module GraphWeaver
|
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
37
|
+
# No response came back: connection refused, DNS failure, TLS handshake,
|
|
38
|
+
# timeout, a socket that died mid-body. The original exception is preserved
|
|
39
|
+
# as #cause. Retriable — though a *read* timeout says nothing about whether
|
|
40
|
+
# the server applied the request, which is why Retry gives a mutation one
|
|
41
|
+
# attempt.
|
|
40
42
|
class TransportError < Error
|
|
41
43
|
extend T::Sig
|
|
42
44
|
|
|
@@ -100,7 +102,23 @@ module GraphWeaver
|
|
|
100
102
|
@body = body
|
|
101
103
|
@headers = headers
|
|
102
104
|
snippet = body.to_s.empty? ? "" : ": #{body.to_s[0, 500]}"
|
|
103
|
-
super("HTTP #{status}#{snippet}")
|
|
105
|
+
super("HTTP #{status}#{snippet}#{" — #{hint}" if hint}")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# What to do about this status, where the status says it. A redirect is
|
|
109
|
+
# not followed — replaying a POST, with its Authorization header, at a
|
|
110
|
+
# host the server named is not ours to decide — so the destination has to
|
|
111
|
+
# reach whoever configured the url.
|
|
112
|
+
REDIRECTS = [301, 302, 303, 307, 308].freeze
|
|
113
|
+
|
|
114
|
+
sig { returns(T.nilable(String)) }
|
|
115
|
+
def hint
|
|
116
|
+
if REDIRECTS.include?(status)
|
|
117
|
+
location = headers["location"]
|
|
118
|
+
"redirects are not followed#{" — point the client at #{location}" if location}"
|
|
119
|
+
elsif [401, 403].include?(status)
|
|
120
|
+
"the server rejected the credentials — check auth: (the token, and its scopes)"
|
|
121
|
+
end
|
|
104
122
|
end
|
|
105
123
|
|
|
106
124
|
# Seconds to wait per the server's Retry-After, which is either a
|
|
@@ -189,12 +207,32 @@ module GraphWeaver
|
|
|
189
207
|
extensions["code"] || @error_type
|
|
190
208
|
end
|
|
191
209
|
|
|
192
|
-
#
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
#
|
|
210
|
+
# Codes a server sets when it rejects the *shape* of a query. Apollo has
|
|
211
|
+
# one flat code; graphql-ruby names the rule that fired, and it is the
|
|
212
|
+
# in-process client this library ships, so its drift-shaped rules are
|
|
213
|
+
# listed rather than guessed at from prose.
|
|
214
|
+
VALIDATION_CODES = T.let(%w[
|
|
215
|
+
GRAPHQL_VALIDATION_FAILED
|
|
216
|
+
undefinedField undefinedType undefinedDirective
|
|
217
|
+
argumentNotAccepted argumentType argumentLiteralsIncompatible
|
|
218
|
+
missingRequiredArguments missingRequiredInputObjectAttribute
|
|
219
|
+
cannotSpreadFragment fragmentOnNonCompositeType
|
|
220
|
+
variableMismatch variableRequiresValidType variableNotDefined
|
|
221
|
+
selectionMismatch invalidOneOfInputObject
|
|
222
|
+
].to_set.freeze, T::Set[String])
|
|
223
|
+
|
|
224
|
+
# For servers that send no code at all. Variable coercion reports through
|
|
225
|
+
# the message in both dialects, and a required input field appearing is
|
|
226
|
+
# unambiguous here: a generated input struct enforces its own required
|
|
227
|
+
# fields, so the app cannot produce that error itself.
|
|
196
228
|
VALIDATION_MESSAGE = T.let(
|
|
197
|
-
|
|
229
|
+
Regexp.union(
|
|
230
|
+
/doesn't exist/i, /Cannot query field/i, /Unknown (field|type|argument)/i,
|
|
231
|
+
/is ?n[o']t defined/i, /undefined (field|type)/i, /No such type/i,
|
|
232
|
+
/can't be spread inside/i, /is missing required arguments/i,
|
|
233
|
+
/doesn't accept argument/i, /Field is not defined on/i,
|
|
234
|
+
/was provided invalid value for .+ \(Expected value to not be null\)/i,
|
|
235
|
+
),
|
|
198
236
|
Regexp,
|
|
199
237
|
)
|
|
200
238
|
|
|
@@ -203,7 +241,8 @@ module GraphWeaver
|
|
|
203
241
|
# changed after generation.
|
|
204
242
|
sig { returns(T::Boolean) }
|
|
205
243
|
def validation?
|
|
206
|
-
code
|
|
244
|
+
# to_s: a nil code is never a member, and sorbet can't narrow a call
|
|
245
|
+
VALIDATION_CODES.include?(code.to_s) || VALIDATION_MESSAGE.match?(message)
|
|
207
246
|
end
|
|
208
247
|
|
|
209
248
|
# The codes servers use to say "you're going too fast". No standard
|
|
@@ -484,6 +523,10 @@ module GraphWeaver
|
|
|
484
523
|
sig { returns(T.nilable(String)) }
|
|
485
524
|
attr_reader :field
|
|
486
525
|
|
|
526
|
+
# The generated input struct class where generation produced one, and
|
|
527
|
+
# the GraphQL type name where it didn't — a federation representation
|
|
528
|
+
# builds a plain Hash, so an entity has only its name there. Branch on
|
|
529
|
+
# #to_h's "struct" instead, which is `to_s` either way.
|
|
487
530
|
sig { returns(T.untyped) }
|
|
488
531
|
attr_reader :struct
|
|
489
532
|
|
|
@@ -491,7 +534,8 @@ module GraphWeaver
|
|
|
491
534
|
def initialize(message, field: nil, struct: nil)
|
|
492
535
|
@field = field
|
|
493
536
|
@struct = struct
|
|
494
|
-
|
|
537
|
+
# the message often IS a sorbet prop error — drop its frame, as TypeError does
|
|
538
|
+
super(message.sub(TypeError::SORBET_CALLER, ""))
|
|
495
539
|
end
|
|
496
540
|
|
|
497
541
|
sig { override.returns(T::Hash[String, T.untyped]) }
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
require "graphql"
|
|
5
5
|
|
|
6
6
|
require_relative "schema_loader"
|
|
7
|
-
require_relative "schemas"
|
|
7
|
+
require_relative "internal/schemas"
|
|
8
8
|
|
|
9
9
|
module GraphWeaver
|
|
10
10
|
# Federation checks that need no network — the supergraph you committed,
|
|
@@ -17,7 +17,7 @@ module GraphWeaver
|
|
|
17
17
|
# A committed supergraph is a snapshot of a composition. Change a
|
|
18
18
|
# subgraph and skip the recompose and it quietly describes a graph that
|
|
19
19
|
# no longer exists — the failure this catches, locally and before merge,
|
|
20
|
-
# where {SchemaLoader.
|
|
20
|
+
# where {SchemaLoader.diff} needs the server and answers a different
|
|
21
21
|
# question (has the *server* drifted from my dump).
|
|
22
22
|
#
|
|
23
23
|
# Both directions, because they mean opposite things:
|
|
@@ -39,7 +39,7 @@ module GraphWeaver
|
|
|
39
39
|
# count.
|
|
40
40
|
#
|
|
41
41
|
# A supergraph is routinely only **partly local** — the rest served by
|
|
42
|
-
# another process, or answered with fabricated data ({
|
|
42
|
+
# another process, or answered with fabricated data ({Internal::Subgraphs}
|
|
43
43
|
# `=> :fake`). Neither can be compared against anything, so the report
|
|
44
44
|
# names three states rather than two: checked, not here, and faked. A
|
|
45
45
|
# clean result that didn't say what it couldn't see would be actively
|
|
@@ -49,6 +49,7 @@ module GraphWeaver
|
|
|
49
49
|
# declares one — so a root can't tell subgraphs apart, and a schema
|
|
50
50
|
# is recognized by the other types it defines.
|
|
51
51
|
ROOTS = %w[Query Mutation Subscription].freeze
|
|
52
|
+
private_constant :ROOTS
|
|
52
53
|
|
|
53
54
|
# { "Product.weight" => ["products"] } — the supergraph says these
|
|
54
55
|
# subgraphs resolve it, and no schema of theirs here defines it
|
|
@@ -78,10 +79,11 @@ module GraphWeaver
|
|
|
78
79
|
def initialize(supergraph: nil, subgraphs: nil, schemas: nil)
|
|
79
80
|
source = (supergraph || GraphWeaver::SchemaLoader.locate_path).to_s
|
|
80
81
|
@table = GraphWeaver::SchemaLoader.routing_table(source)
|
|
81
|
-
# SDL passed as content has no name to print
|
|
82
|
-
|
|
83
|
-
@
|
|
84
|
-
@
|
|
82
|
+
# SDL passed as content has no name to print — asked the way the
|
|
83
|
+
# loader asks it, which a one-line supergraph doesn't fool
|
|
84
|
+
@source = GraphWeaver::SchemaLoader.sdl_content?(source) ? "the supergraph" : source
|
|
85
|
+
@given = @table.named_subgraphs(subgraphs)
|
|
86
|
+
@schemas = schemas || GraphWeaver::Internal::Schemas.loaded
|
|
85
87
|
@stale = {}
|
|
86
88
|
@uncomposed = {}
|
|
87
89
|
@skipped = {}
|
|
@@ -129,20 +131,6 @@ module GraphWeaver
|
|
|
129
131
|
STALE = "stale — the supergraph carries these, no schema here defines them (recompose):"
|
|
130
132
|
UNCOMPOSED = "not composed in — a schema here defines these, the supergraph doesn't carry them:"
|
|
131
133
|
|
|
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
134
|
def compare
|
|
147
135
|
@table.subgraphs.each do |name|
|
|
148
136
|
next unless (fitting = comparable(name))
|
|
@@ -194,7 +182,7 @@ module GraphWeaver
|
|
|
194
182
|
next unless @table.owners(type_name, field_name).include?(name)
|
|
195
183
|
|
|
196
184
|
coordinate = "#{type_name}.#{field_name}"
|
|
197
|
-
next if fitting.any? { |schema| GraphWeaver::Schemas.defines?(schema, coordinate) }
|
|
185
|
+
next if fitting.any? { |schema| GraphWeaver::Internal::Schemas.defines?(schema, coordinate) }
|
|
198
186
|
|
|
199
187
|
(@stale[coordinate] ||= []) << name
|
|
200
188
|
end
|
data/lib/graph_weaver/hints.rb
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
require "sorbet-runtime"
|
|
5
5
|
|
|
6
6
|
require_relative "errors"
|
|
7
|
+
require_relative "internal"
|
|
7
8
|
require_relative "inflect"
|
|
8
9
|
|
|
9
10
|
module GraphWeaver
|
|
11
|
+
# Called by generated code — not semver'd for direct use.
|
|
12
|
+
#
|
|
10
13
|
# Included in generated response structs. GraphQL's camelCase fields
|
|
11
14
|
# become snake_case props, and reaching for the wire name is a classic
|
|
12
15
|
# stumble — result.nameWithOwner instead of result.name_with_owner.
|
|
@@ -29,7 +32,7 @@ module GraphWeaver
|
|
|
29
32
|
suggestion = if known.include?(prop)
|
|
30
33
|
prop # a wire-cased key — the exact snake_case prop exists
|
|
31
34
|
else
|
|
32
|
-
GraphWeaver.did_you_mean(known, prop)
|
|
35
|
+
GraphWeaver::Internal::Util.did_you_mean(known, prop)
|
|
33
36
|
end
|
|
34
37
|
suggestion ? "#{key} (did you mean '#{suggestion}'?)" : key
|
|
35
38
|
end
|
|
@@ -40,6 +43,77 @@ module GraphWeaver
|
|
|
40
43
|
)
|
|
41
44
|
end
|
|
42
45
|
|
|
46
|
+
# Wraps one field's cast in generated from_h so a failure says which
|
|
47
|
+
# field. A scalar's cast is arbitrary Ruby — Float(), Date.iso8601, an
|
|
48
|
+
# app's Money.parse — and its complaint is about the value alone
|
|
49
|
+
# ("invalid date"), which locates nothing on a struct holding four
|
|
50
|
+
# dates. Sorbet's own prop errors already name the prop, so this is
|
|
51
|
+
# only on the leaves that cast.
|
|
52
|
+
def self.field(struct, key)
|
|
53
|
+
yield
|
|
54
|
+
rescue GraphWeaver::Error
|
|
55
|
+
raise # a nested struct already named its own field
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
raise GraphWeaver::TypeError.new(struct:, message: "#{key}: #{e.message}")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A wire value the generated enum doesn't have — the response-side twin
|
|
61
|
+
# of InputStruct.enum. Almost always drift (the server grew a value since
|
|
62
|
+
# you generated) rather than a bad value, and T::Enum's own KeyError says
|
|
63
|
+
# neither that nor which values exist. Raised bare so the enclosing
|
|
64
|
+
# Hints.field brands it with the field.
|
|
65
|
+
def self.enum(type, value)
|
|
66
|
+
type.try_deserialize(value) || drifted!(type, value, type.values.map(&:serialize))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# the same, for an enum mapped onto an app-owned T::Enum, where the wire
|
|
70
|
+
# table rather than the type knows the accepted values
|
|
71
|
+
def self.mapped_enum(type, table, value)
|
|
72
|
+
table.fetch(value) { drifted!(type, value, table.keys) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.drifted!(type, value, values)
|
|
76
|
+
raise KeyError, "#{value.inspect} is not a #{type} — expected one of: " \
|
|
77
|
+
"#{values.sort.join(", ")}; a value the server added since you generated " \
|
|
78
|
+
"needs a regenerate, or register_enum fallback: to absorb them"
|
|
79
|
+
end
|
|
80
|
+
private_class_method :drifted!
|
|
81
|
+
|
|
82
|
+
# The message for a response that wouldn't cast. sorbet names the prop
|
|
83
|
+
# and the value but not whose bug it is, and an ID the server sent as
|
|
84
|
+
# its raw integer primary key is the case that keeps happening — so
|
|
85
|
+
# say that GraphQL requires the quotes, and how to take it anyway.
|
|
86
|
+
def self.cast_message(struct, data, error)
|
|
87
|
+
message = error.message.sub(GraphWeaver::TypeError::SORBET_CALLER, "")
|
|
88
|
+
keys = unquoted_keys(struct, data)
|
|
89
|
+
return message if keys.empty?
|
|
90
|
+
|
|
91
|
+
"#{message} — the server sent #{keys.join(", ")} unquoted; GraphQL serializes ID and " \
|
|
92
|
+
"String as JSON strings, so that is the server being out of spec. To take it anyway, " \
|
|
93
|
+
'register the scalar loosely: GraphWeaver.register_scalar("ID", "T.untyped")'
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Response keys whose prop would take a String but whose value is
|
|
97
|
+
# another JSON scalar. Narrow on purpose: a prop that casts (a Date, an
|
|
98
|
+
# enum) legitimately arrives as some other type, so only the
|
|
99
|
+
# pass-through String ones say anything.
|
|
100
|
+
def self.unquoted_keys(struct, data)
|
|
101
|
+
return [] unless data.is_a?(Hash) && struct.respond_to?(:props)
|
|
102
|
+
|
|
103
|
+
props = struct.props
|
|
104
|
+
data.filter_map do |key, value|
|
|
105
|
+
next unless value.is_a?(Numeric) || value == true || value == false
|
|
106
|
+
|
|
107
|
+
prop = props[GraphWeaver::Inflect.underscore(key.to_s).to_sym]
|
|
108
|
+
next unless prop
|
|
109
|
+
|
|
110
|
+
# :type is a raw Class for a bare-class prop, a T::Types::Base otherwise
|
|
111
|
+
type = T::Utils.coerce(prop[:type])
|
|
112
|
+
key.inspect if type.valid?("") && !type.valid?(value)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
private_class_method :unquoted_keys
|
|
116
|
+
|
|
43
117
|
def method_missing(name, *args, &block)
|
|
44
118
|
if args.empty? && (hint = prop_hint(name.to_s))
|
|
45
119
|
raise NoMethodError, "undefined method '#{name}' for #{self.class} — #{hint}"
|
|
@@ -67,7 +141,7 @@ module GraphWeaver
|
|
|
67
141
|
# a guess, not a mapping — spellcheck the (underscored) miss
|
|
68
142
|
# against the props that exist, so typos in either casing land
|
|
69
143
|
props = T.unsafe(self.class).props.keys.map(&:to_s)
|
|
70
|
-
suggestion = GraphWeaver.did_you_mean(props, prop)
|
|
144
|
+
suggestion = GraphWeaver::Internal::Util.did_you_mean(props, prop)
|
|
71
145
|
"did you mean '#{suggestion}'?" if suggestion
|
|
72
146
|
end
|
|
73
147
|
end
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
require "json"
|
|
5
5
|
|
|
6
6
|
require_relative "errors"
|
|
7
|
+
require_relative "internal"
|
|
7
8
|
require_relative "parsing"
|
|
8
9
|
require_relative "transport"
|
|
9
10
|
|
|
@@ -45,10 +46,10 @@ class GraphWeaver::InProcess
|
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
def execute(query, variables: {}, operation_name: nil)
|
|
48
|
-
operation_name ||= GraphWeaver::
|
|
49
|
+
operation_name ||= GraphWeaver::Internal::Wire.operation_name(query)
|
|
49
50
|
payload = { url: nil, schema: @schema.to_s, operation: operation_name }
|
|
50
51
|
|
|
51
|
-
GraphWeaver.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
|
|
52
|
+
GraphWeaver::Internal::Log.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
|
|
52
53
|
perform(query, variables, operation_name, payload)
|
|
53
54
|
end
|
|
54
55
|
end
|
|
@@ -58,15 +59,17 @@ class GraphWeaver::InProcess
|
|
|
58
59
|
private def perform(query, variables, operation_name, payload)
|
|
59
60
|
# same tag/truncation as the network transports, so one log reads the
|
|
60
61
|
# same whichever side of the seam a query ran on
|
|
61
|
-
tag = GraphWeaver.logger && GraphWeaver::
|
|
62
|
+
tag = GraphWeaver.logger && GraphWeaver::Internal::Wire.log_tag(operation_name)
|
|
62
63
|
|
|
63
|
-
GraphWeaver.log(:debug) do
|
|
64
|
-
"in-process #{@schema} #{tag} variables=#{JSON.generate(variables)}\n" \
|
|
65
|
-
"#{GraphWeaver::
|
|
64
|
+
GraphWeaver::Internal::Log.log(:debug) do
|
|
65
|
+
"in-process #{@schema} #{tag} variables=#{JSON.generate(GraphWeaver::Internal::Log.filter_variables(variables))}\n" \
|
|
66
|
+
"#{GraphWeaver::Internal::Wire.truncate_for_log(query)}"
|
|
66
67
|
end
|
|
67
68
|
|
|
68
|
-
result = GraphWeaver.log_timed(:debug, "in-process #{@schema} #{tag} completed") do
|
|
69
|
-
|
|
69
|
+
result = GraphWeaver::Internal::Log.log_timed(:debug, "in-process #{@schema} #{tag} completed") do
|
|
70
|
+
# a copy per query: graphql-ruby writes a resolver's `context[...] =`
|
|
71
|
+
# into the hash it is handed, and one client serves every request
|
|
72
|
+
@schema.execute(query, variables:, operation_name:, context: @context.dup)
|
|
70
73
|
end
|
|
71
74
|
|
|
72
75
|
# the same key the network transports set, so one instrumenter
|
data/lib/graph_weaver/inflect.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
module GraphWeaver
|
|
5
|
+
# Called by generated code — not semver'd for direct use.
|
|
6
|
+
#
|
|
5
7
|
# GraphQL names are plain camelCase/SCREAMING_SNAKE — no acronym edge
|
|
6
8
|
# cases, so minimal inflection beats an activesupport dependency
|
|
7
9
|
module Inflect
|
|
@@ -7,6 +7,8 @@ require_relative "errors"
|
|
|
7
7
|
require_relative "hints"
|
|
8
8
|
|
|
9
9
|
module GraphWeaver
|
|
10
|
+
# Called by generated code — not semver'd for direct use.
|
|
11
|
+
#
|
|
10
12
|
# Runtime for generated input structs. Each struct declares its typed
|
|
11
13
|
# consts plus a compact FIELDS table — (prop, wire name, requiredness,
|
|
12
14
|
# serializer, coercer) per field, with the conversions emitted as
|
|
@@ -19,27 +21,82 @@ module GraphWeaver
|
|
|
19
21
|
|
|
20
22
|
# serializer/coercer are code-as-data from the generated file; nil
|
|
21
23
|
# means identity (the wire value passes through untouched)
|
|
22
|
-
Field =
|
|
24
|
+
Field = Data.define(:prop, :wire, :required, :serializer, :coercer)
|
|
25
|
+
|
|
26
|
+
# An enum reaching the library as input — an execute kwarg or an input
|
|
27
|
+
# field — as the member or its wire value. Generated code calls these
|
|
28
|
+
# rather than T::Enum.deserialize / the wire table directly: both raise a
|
|
29
|
+
# bare KeyError naming an anonymous module and none of the values they
|
|
30
|
+
# would have taken.
|
|
31
|
+
def self.enum(type, value)
|
|
32
|
+
return value if value.is_a?(type)
|
|
33
|
+
|
|
34
|
+
type.try_deserialize(value) || invalid_enum!(type, value, type.values.map(&:serialize))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# the same, for an enum mapped onto an app-owned T::Enum (register_enum),
|
|
38
|
+
# where the wire table rather than the type knows the accepted values
|
|
39
|
+
def self.mapped_enum(type, table, value)
|
|
40
|
+
return value if value.is_a?(type)
|
|
41
|
+
|
|
42
|
+
table.fetch(value) { invalid_enum!(type, value, table.keys) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Names the input field a coercion refused — a scalar's coercer, an
|
|
46
|
+
# enum's, or a nested input's — since the complaint underneath is about
|
|
47
|
+
# the value alone. A nested error that already named a field keeps it:
|
|
48
|
+
# the innermost input is the one that actually held the bad value.
|
|
49
|
+
def self.field(struct, prop)
|
|
50
|
+
yield
|
|
51
|
+
rescue GraphWeaver::InputError => e
|
|
52
|
+
raise if e.field && !GraphWeaver::Internal::Redact.filtered?(prop)
|
|
53
|
+
|
|
54
|
+
raise GraphWeaver::InputError.new(
|
|
55
|
+
"#{prop}: #{GraphWeaver::Internal::Redact.detail(prop, e.message)}",
|
|
56
|
+
field: e.field || prop.to_s, struct: e.struct || struct,
|
|
57
|
+
)
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
raise GraphWeaver::InputError.new(
|
|
60
|
+
"#{prop}: #{GraphWeaver::Internal::Redact.detail(prop, e.message)}", field: prop.to_s, struct:,
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Raised bare, like Hints.drifted!: the enclosing .field or Coerce.variable
|
|
65
|
+
# knows the key, and so is the only layer that can decide whether this
|
|
66
|
+
# value may be named.
|
|
67
|
+
def self.invalid_enum!(type, value, values)
|
|
68
|
+
raise KeyError, "#{value.inspect} is not a valid #{type} — expected one of: #{values.sort.join(", ")}"
|
|
69
|
+
end
|
|
70
|
+
private_class_method :invalid_enum!
|
|
23
71
|
|
|
24
72
|
def self.included(base)
|
|
25
73
|
base.extend(ClassMethods)
|
|
26
74
|
end
|
|
27
75
|
|
|
28
|
-
#
|
|
76
|
+
# Which props the caller actually named, when we know. GraphQL tells an
|
|
77
|
+
# absent input field from an explicit null, and a Hash can say which it
|
|
78
|
+
# meant — so .coerce records it. A struct built with .new can't: every
|
|
79
|
+
# unset prop is nil either way, and nil there stays "leave it out".
|
|
80
|
+
attr_accessor :supplied
|
|
81
|
+
|
|
82
|
+
# the wire hash — an optional field stays off it unless the caller
|
|
83
|
+
# supplied the nil
|
|
29
84
|
def serialize
|
|
85
|
+
given = supplied
|
|
30
86
|
wire = self.class.const_get(:FIELDS).each_with_object({}) do |field, out|
|
|
31
87
|
value = public_send(field.prop)
|
|
32
|
-
next if value.nil? && !field.required
|
|
88
|
+
next if value.nil? && !field.required && !given&.include?(field.prop)
|
|
33
89
|
|
|
34
90
|
out[field.wire] = field.serializer && !value.nil? ? field.serializer.call(value) : value
|
|
35
91
|
end
|
|
36
92
|
|
|
37
|
-
# @oneOf declares "exactly one of these", but every field
|
|
38
|
-
# nothing before here can enforce it — not the struct's
|
|
39
|
-
# server until the round trip
|
|
40
|
-
if
|
|
93
|
+
# @oneOf declares "exactly one of these, and not null", but every field
|
|
94
|
+
# is nullable, so nothing before here can enforce it — not the struct's
|
|
95
|
+
# types, not the server until the round trip
|
|
96
|
+
if self.class.const_defined?(:ONE_OF, false) && (wire.size != 1 || wire.values.first.nil?)
|
|
97
|
+
supplied_names = wire.empty? ? "none" : wire.keys.sort.join(", ")
|
|
41
98
|
raise GraphWeaver::InputError.new(
|
|
42
|
-
"#{self.class} is @oneOf — supply exactly one field, got #{
|
|
99
|
+
"#{self.class} is @oneOf — supply exactly one field, non-null, got #{supplied_names}",
|
|
43
100
|
struct: self.class,
|
|
44
101
|
)
|
|
45
102
|
end
|
|
@@ -69,17 +126,63 @@ module GraphWeaver
|
|
|
69
126
|
GraphWeaver::Hints.validate_keys!(self, value)
|
|
70
127
|
|
|
71
128
|
fields = T.unsafe(self).const_get(:FIELDS)
|
|
72
|
-
|
|
129
|
+
given = fields.select { |field| value.key?(field.prop) || value.key?(field.prop.to_s) }.map(&:prop)
|
|
130
|
+
supplied = fields.to_h do |field|
|
|
73
131
|
raw = value.key?(field.prop) ? value[field.prop] : value[field.prop.to_s]
|
|
74
|
-
[field.prop, raw.nil? || field.coercer.nil?
|
|
75
|
-
|
|
132
|
+
next [field.prop, raw] if raw.nil? || field.coercer.nil?
|
|
133
|
+
|
|
134
|
+
# a coercer is arbitrary Ruby — Coerce.integer, Date.iso8601, a
|
|
135
|
+
# nested .coerce — and its complaint is about the value alone
|
|
136
|
+
[field.prop, GraphWeaver::InputStruct.field(self, field.prop) { field.coercer.call(raw) }]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# FIELDS knows which are required, so say what is missing — sorbet's
|
|
140
|
+
# own complaint describes the symptom ("Can't set .name to nil") and
|
|
141
|
+
# names only the first one it reaches
|
|
142
|
+
missing = fields.select { |field| field.required && supplied[field.prop].nil? }.map(&:prop)
|
|
143
|
+
unless missing.empty?
|
|
144
|
+
raise GraphWeaver::InputError.new(
|
|
145
|
+
"missing required key(s) for #{self}: #{missing.join(", ")}",
|
|
146
|
+
field: missing.join(", "), struct: self,
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
T.unsafe(self).new(**supplied).tap { |struct| struct.supplied = given }
|
|
76
151
|
rescue GraphWeaver::InputError
|
|
77
152
|
raise # already contextualized by a nested input / enum coercion
|
|
78
153
|
rescue ::TypeError, ::ArgumentError, KeyError => e
|
|
79
154
|
# a wrong-typed field, a missing required field, or an out-of-range
|
|
80
155
|
# enum — surface one branded, structured error for a 422. (`::` so the
|
|
81
156
|
# rescue catches Ruby's TypeError, not GraphWeaver::TypeError.)
|
|
82
|
-
raise
|
|
157
|
+
raise mistyped(supplied) ||
|
|
158
|
+
GraphWeaver::InputError.new("invalid input for #{self}: #{e.message}", struct: self)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
# The prop whose value its own type refuses, reported the way every
|
|
164
|
+
# other input failure is. Only sorbet stands between a field with no
|
|
165
|
+
# coercer and the struct, and it names the prop and the value inside
|
|
166
|
+
# one free-text sentence — which reads as the library's own bug, and
|
|
167
|
+
# which no filter can see into.
|
|
168
|
+
def mistyped(supplied)
|
|
169
|
+
return unless supplied
|
|
170
|
+
|
|
171
|
+
T.unsafe(self).props.each do |prop, info|
|
|
172
|
+
value = supplied[prop]
|
|
173
|
+
# :type_object carries the nilable-ness the prop was declared with;
|
|
174
|
+
# :type is that unwrapped, which is the half worth naming — an
|
|
175
|
+
# absent optional field is nil and legal, and a missing required
|
|
176
|
+
# one was reported by name before we got here
|
|
177
|
+
next if T::Utils.coerce(info[:type_object]).valid?(value)
|
|
178
|
+
|
|
179
|
+
return GraphWeaver::InputError.new(
|
|
180
|
+
"#{prop}: expected #{T::Utils.coerce(info[:type])}, " \
|
|
181
|
+
"got #{GraphWeaver::Internal::Redact.detail(prop, value.inspect)}",
|
|
182
|
+
field: prop.to_s, struct: self,
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
nil
|
|
83
186
|
end
|
|
84
187
|
end
|
|
85
188
|
end
|