graph_weaver 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1447 -1
- data/Gemfile +8 -0
- data/Gemfile.lock +151 -2
- data/README.md +20 -6
- data/docs/alternatives.md +201 -0
- data/docs/cassettes.md +17 -1
- data/docs/errors.md +382 -17
- data/docs/federation.md +469 -63
- data/docs/generated_modules.md +231 -15
- data/docs/getting_started.md +497 -104
- data/docs/i18n.md +234 -0
- data/docs/logging.md +160 -24
- data/docs/real_world.md +28 -0
- data/docs/scalars.md +190 -26
- data/docs/testing.md +457 -58
- data/docs/transports.md +164 -19
- data/docs/upgrading.md +328 -3
- data/graph_weaver.gemspec +7 -0
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +47 -10
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +404 -197
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/errors.rb +264 -34
- data/lib/graph_weaver/federation.rb +119 -26
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +17 -11
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +78 -0
- data/lib/graph_weaver/internal/headers.rb +51 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +40 -4
- data/lib/graph_weaver/internal.rb +183 -1
- data/lib/graph_weaver/log_subscriber.rb +66 -0
- data/lib/graph_weaver/logging.rb +136 -12
- data/lib/graph_weaver/query_module.rb +36 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +33 -5
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +221 -49
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +6 -5
- data/lib/graph_weaver/testing/endpoint.rb +106 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +133 -44
- data/lib/graph_weaver/testing/router.rb +58 -11
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +46 -4
- data/lib/graph_weaver/transport.rb +109 -26
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +474 -106
- metadata +56 -1
data/lib/graph_weaver/coerce.rb
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# typed: true
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require "date" # Date/DateTime, named below
|
|
5
|
+
|
|
4
6
|
require_relative "errors"
|
|
7
|
+
require_relative "internal/refusal"
|
|
5
8
|
|
|
6
9
|
module GraphWeaver
|
|
7
10
|
# Called by generated code — not semver'd for direct use.
|
|
@@ -24,66 +27,139 @@ module GraphWeaver
|
|
|
24
27
|
INTEGER = /\A[+-]?\d+\z/
|
|
25
28
|
NUMBER = /\A[+-]?\d+(\.\d+)?([eE][+-]?\d+)?\z/
|
|
26
29
|
|
|
30
|
+
# said where a date and a timestamp are given for each other
|
|
31
|
+
DATE_HINT = "pass .to_date if dropping the time of day is what you meant"
|
|
32
|
+
TIME_HINT = "a Date has no time of day — pass the Time you mean"
|
|
33
|
+
private_constant :DATE_HINT, :TIME_HINT
|
|
34
|
+
|
|
27
35
|
class << self
|
|
28
|
-
|
|
36
|
+
# `scalar` is the schema's name for what this rule builds, and it is what
|
|
37
|
+
# the refusal reports — as the message and as #details[:type], which an
|
|
38
|
+
# app translates for a user. It defaults to the GraphQL scalar the rule
|
|
39
|
+
# is named for; generated code passes the schema's own name, so a
|
|
40
|
+
# `register_scalar("BigInt", Integer)` field refuses as a BigInt.
|
|
41
|
+
def integer(value, scalar = "Int")
|
|
29
42
|
case value
|
|
30
43
|
when Integer then value
|
|
31
|
-
when Float then whole(value)
|
|
32
|
-
when String then INTEGER.match?(value.strip) ? Integer(value.strip, 10) : unparseable(value,
|
|
33
|
-
else refuse(value,
|
|
44
|
+
when Float then whole(value, scalar)
|
|
45
|
+
when String then INTEGER.match?(value.strip) ? Integer(value.strip, 10) : unparseable(value, scalar)
|
|
46
|
+
else refuse(value, scalar)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def float(value, scalar = "Float")
|
|
51
|
+
case value
|
|
52
|
+
when Float then finite(value, scalar)
|
|
53
|
+
when Integer then finite(value.to_f, scalar)
|
|
54
|
+
when String then NUMBER.match?(value.strip) ? finite(Float(value.strip), scalar) : unparseable(value, scalar)
|
|
55
|
+
else refuse(value, scalar)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A date stays a Date and a timestamp a Time. Converting between them
|
|
60
|
+
# drops the time of day or invents a midnight, so a cross-type value is
|
|
61
|
+
# refused rather than guessed at — by class, since a timestamp printed
|
|
62
|
+
# in full looks a great deal like a date.
|
|
63
|
+
def date(value, scalar = "Date")
|
|
64
|
+
case value
|
|
65
|
+
# DateTime is a Date to Ruby and a timestamp to everyone else
|
|
66
|
+
when DateTime, Time then cross(value, scalar, DATE_HINT)
|
|
67
|
+
when Date then value
|
|
68
|
+
when String then parsing(scalar, value) { Date.iso8601(value) }
|
|
69
|
+
else time_like?(value) ? cross(value, scalar, DATE_HINT) : refuse(value, scalar)
|
|
34
70
|
end
|
|
35
71
|
end
|
|
36
72
|
|
|
37
|
-
def
|
|
73
|
+
def time(value, scalar = "Time")
|
|
38
74
|
case value
|
|
39
|
-
when
|
|
40
|
-
when
|
|
41
|
-
when
|
|
42
|
-
|
|
75
|
+
when Time then value
|
|
76
|
+
when DateTime then value.to_time # the same instant in another class
|
|
77
|
+
when Date then cross(value, scalar, TIME_HINT)
|
|
78
|
+
when String then parsing(scalar, value) { Time.parse(value) }
|
|
79
|
+
else time_like?(value) ? value.to_time : refuse(value, scalar)
|
|
43
80
|
end
|
|
44
81
|
end
|
|
45
82
|
|
|
83
|
+
# A timestamp on the wire: ISO 8601, carrying microseconds only when the
|
|
84
|
+
# value has them. A server that writes sub-second times (every JS one
|
|
85
|
+
# does) round-trips through Ruby unchanged, and a value that doesn't
|
|
86
|
+
# sends the bytes it always has.
|
|
87
|
+
def timestamp(value)
|
|
88
|
+
# DateTime spells its fraction #sec_fraction, Time (and an
|
|
89
|
+
# ActiveSupport::TimeWithZone) spell it #subsec
|
|
90
|
+
fraction = value.respond_to?(:subsec) ? value.subsec : value.sec_fraction
|
|
91
|
+
value.iso8601(fraction.zero? ? 0 : 6)
|
|
92
|
+
end
|
|
93
|
+
|
|
46
94
|
# Ruby has no Kernel#Boolean, and every string rule ("0", "off", "no")
|
|
47
95
|
# is somebody's convention — so refuse rather than pick one.
|
|
48
|
-
def boolean(value)
|
|
96
|
+
def boolean(value, scalar = "Boolean")
|
|
49
97
|
return value if value == true || value == false
|
|
50
98
|
|
|
51
|
-
refuse(value,
|
|
99
|
+
refuse(value, scalar, "there is no one right reading of it — convert at the call site")
|
|
52
100
|
end
|
|
53
101
|
|
|
54
|
-
def string(value)
|
|
102
|
+
def string(value, scalar = "String")
|
|
55
103
|
return value if value.is_a?(String)
|
|
56
104
|
|
|
57
|
-
refuse(value,
|
|
105
|
+
refuse(value, scalar)
|
|
58
106
|
end
|
|
59
107
|
|
|
60
108
|
# The GraphQL spec has ID serialize as a String but accept an integer
|
|
61
109
|
# input, which is `execute(id: user.id)` — the everyday Rails call.
|
|
62
110
|
# String gets no such licence: an Integer where a String belongs is
|
|
63
111
|
# more often a bug than a spelling.
|
|
64
|
-
def id(value)
|
|
112
|
+
def id(value, scalar = "ID")
|
|
65
113
|
case value
|
|
66
114
|
when String then value
|
|
67
115
|
when Integer then value.to_s
|
|
68
|
-
else refuse(value,
|
|
116
|
+
else refuse(value, scalar)
|
|
69
117
|
end
|
|
70
118
|
end
|
|
71
119
|
|
|
120
|
+
# A registration's own `cast:` — Date.iso8601, Money.parse — standing in
|
|
121
|
+
# for a Coerce rule the library has none of. Two things the codec can't
|
|
122
|
+
# do for itself: the pass-through guard (a value already of the type is
|
|
123
|
+
# not rebuilt), and a verdict, since a cast complains about the value
|
|
124
|
+
# alone — "no implicit conversion of Integer into String" names neither
|
|
125
|
+
# what was expected nor which half of the value was wrong.
|
|
126
|
+
def cast(type, value, scalar = nil)
|
|
127
|
+
scalar ||= type.is_a?(Module) ? type.name : type.to_s
|
|
128
|
+
# DateTime is a Date to Ruby and a timestamp to everyone else, so it is
|
|
129
|
+
# not "already a Date" however the app spelled its cast: .date's own
|
|
130
|
+
# refusal, applied BEFORE the cast rather than instead of it.
|
|
131
|
+
cross(value, scalar, DATE_HINT) if type.equal?(::Date) && value.is_a?(::DateTime)
|
|
132
|
+
# a type given as a type string ("T::Hash[...]") names no class to ask
|
|
133
|
+
return value if type.is_a?(Module) && value.is_a?(type)
|
|
134
|
+
|
|
135
|
+
parsing(scalar, value) { yield value }
|
|
136
|
+
end
|
|
137
|
+
|
|
72
138
|
# Brands one variable's coercion failure with the variable and the
|
|
73
139
|
# operation: a cast complains about the value alone ("invalid date"),
|
|
74
|
-
# which locates nothing in an app that runs a hundred queries.
|
|
140
|
+
# which locates nothing in an app that runs a hundred queries. The
|
|
141
|
+
# variable is also the root of #path, so a nested refusal comes out
|
|
142
|
+
# holding the whole route from the kwarg down to the field.
|
|
75
143
|
def variable(name, operation, value)
|
|
76
144
|
yield value
|
|
77
145
|
rescue GraphWeaver::InputError => e
|
|
78
146
|
raise GraphWeaver::InputError.new(
|
|
79
|
-
"#{at(name, operation)}: #{Internal::Redact.detail(name, e.message)}",
|
|
147
|
+
"#{at(name, operation)}: #{Internal::Redact.detail(name, e.message)}",
|
|
148
|
+
kind: e.kind, path: [name, *e.path], coordinate: e.coordinate,
|
|
149
|
+
# #value is the value AT #path: this layer owns it only when nothing
|
|
150
|
+
# inner named a field (so a missing one stays valueless, as it is) —
|
|
151
|
+
# but the variable's own filter covers everything beneath it
|
|
152
|
+
value: Internal::Redact.value(name, e.path.empty? ? value : e.value),
|
|
153
|
+
details: e.details, struct: e.struct,
|
|
80
154
|
)
|
|
81
155
|
rescue StandardError => e
|
|
82
156
|
# a cast complains about the value without quoting it ("invalid date")
|
|
83
|
-
|
|
84
|
-
got = " (got #{
|
|
157
|
+
quoted = shown(value)
|
|
158
|
+
got = " (got #{quoted})" unless e.message.include?(quoted)
|
|
85
159
|
raise GraphWeaver::InputError.new(
|
|
86
|
-
"#{at(name, operation)}: #{Internal::Redact.detail(name, "#{e.message}#{got}")}",
|
|
160
|
+
"#{at(name, operation)}: #{Internal::Redact.detail(name, "#{e.message}#{got}")}",
|
|
161
|
+
kind: Internal::Refusal.kind_of(e), path: [name],
|
|
162
|
+
value: Internal::Redact.value(name, value), details: Internal::Refusal.details_of(e),
|
|
87
163
|
)
|
|
88
164
|
end
|
|
89
165
|
|
|
@@ -91,23 +167,76 @@ module GraphWeaver
|
|
|
91
167
|
|
|
92
168
|
def at(name, operation) = operation ? "$#{name} of #{operation}" : "$#{name}"
|
|
93
169
|
|
|
94
|
-
|
|
170
|
+
# A stdlib parser's own complaint, kept word for word ("invalid date")
|
|
171
|
+
# and labelled with the scalar the schema named — which the parser,
|
|
172
|
+
# handed only a String, has no way to know. Ruby's own convention splits
|
|
173
|
+
# the two: ArgumentError is wrong CONTENT, and the parser says more
|
|
174
|
+
# about it than we could; TypeError is a wrong CLASS, which the parser
|
|
175
|
+
# describes in terms of its own argument ("no implicit conversion of
|
|
176
|
+
# Integer into String") and we can name properly.
|
|
177
|
+
def parsing(scalar, value = nil)
|
|
178
|
+
yield
|
|
179
|
+
rescue ::ArgumentError => e
|
|
180
|
+
raise Internal::Refusal.brand(e, :unparseable, type: scalar)
|
|
181
|
+
rescue ::TypeError
|
|
182
|
+
raise mismatch(::TypeError, "#{expected(scalar)}, got #{shown(value)}", scalar)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Kernel#Float("1e400") is Infinity rather than a raise, and so is
|
|
186
|
+
# (10**400).to_f — while JSON has no spelling for a non-finite number
|
|
187
|
+
# and the GraphQL spec excludes them from Float outright. Refusing here
|
|
188
|
+
# names the variable; the transport otherwise complains that the
|
|
189
|
+
# variables aren't serializable, a whole query away from the value.
|
|
190
|
+
def finite(value, scalar = "Float")
|
|
191
|
+
return value if value.finite?
|
|
192
|
+
|
|
193
|
+
# a number, but not one GraphQL's Float admits — no conversion applies
|
|
194
|
+
raise mismatch(ArgumentError, "#{expected(scalar)}, got #{shown(value)} — not a finite number", scalar)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def whole(value, scalar = "Int")
|
|
95
198
|
# Integer(2.5) is 2 — a silent loss where refusing costs nothing
|
|
96
199
|
return value.to_i if value.finite? && (value % 1).zero?
|
|
97
200
|
|
|
98
|
-
raise ArgumentError, "#{expected(
|
|
201
|
+
raise mismatch(ArgumentError, "#{expected(scalar)}, got #{shown(value)} — not a whole number", scalar)
|
|
99
202
|
end
|
|
100
203
|
|
|
101
204
|
def unparseable(value, scalar)
|
|
102
|
-
raise
|
|
205
|
+
raise Internal::Refusal.brand(
|
|
206
|
+
ArgumentError.new("#{expected(scalar)}, got #{shown(value)}"), :unparseable, type: scalar
|
|
207
|
+
)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# the refusal a caller sees is the plain Ruby error it always was —
|
|
211
|
+
# the kind rides along for the layer that brands it (see Refusal)
|
|
212
|
+
def mismatch(klass, message, scalar)
|
|
213
|
+
Internal::Refusal.brand(klass.new(message), :type_mismatch, type: scalar)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# ActiveSupport::TimeWithZone — what Time.zone.now returns — is not a
|
|
217
|
+
# Time but is one for every purpose, and #to_time is lossless. acts_like?
|
|
218
|
+
# is Rails' own duck-type check, so nothing answers it by accident.
|
|
219
|
+
# (The real TimeWithZone also answers is_a?(Time); this doesn't rely on
|
|
220
|
+
# it, so a value that stops lying keeps working.)
|
|
221
|
+
def time_like?(value)
|
|
222
|
+
value.respond_to?(:acts_like?) && value.acts_like?(:time) && value.respond_to?(:to_time)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def cross(value, scalar, hint)
|
|
226
|
+
raise mismatch(::TypeError, "#{expected(scalar)}, got a #{value.class} — #{hint}", scalar)
|
|
103
227
|
end
|
|
104
228
|
|
|
105
|
-
# ::TypeError — inside GraphWeaver, a bare TypeError is ours
|
|
106
229
|
def refuse(value, scalar, hint = nil)
|
|
107
|
-
raise ::TypeError, "#{expected(scalar)}, got #{value
|
|
230
|
+
raise mismatch(::TypeError, "#{expected(scalar)}, got #{shown(value)}#{" — #{hint}" if hint}", scalar)
|
|
108
231
|
end
|
|
109
232
|
|
|
110
|
-
|
|
233
|
+
# the value a refusal quotes: scrubbed at every depth, so a filtered key
|
|
234
|
+
# inside an input object never reaches a sentence (see Redact)
|
|
235
|
+
def shown(value) = Internal::Redact.shown(value)
|
|
236
|
+
|
|
237
|
+
# the article by the initial, so a registered Ruby class ("an Integer")
|
|
238
|
+
# reads as well as the scalars ("an Int", "an ID", "a Date")
|
|
239
|
+
def expected(scalar) = "expected #{scalar.start_with?(/[AEIOU]/) ? "an" : "a"} #{scalar}"
|
|
111
240
|
end
|
|
112
241
|
end
|
|
113
242
|
end
|