graph_weaver 0.4.4 → 0.4.6
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 +43 -0
- data/Gemfile.lock +2 -2
- data/docs/scalars.md +23 -5
- data/lib/graph_weaver/client.rb +1 -1
- data/lib/graph_weaver/codegen/emit.rb +7 -0
- data/lib/graph_weaver/codegen/enum_type.rb +37 -11
- data/lib/graph_weaver/codegen/scalar_type.rb +4 -1
- data/lib/graph_weaver/codegen.rb +149 -55
- data/lib/graph_weaver/hints.rb +3 -5
- data/lib/graph_weaver/input_struct.rb +7 -0
- data/lib/graph_weaver/response.rb +8 -1
- data/lib/graph_weaver/schema_loader.rb +34 -6
- data/lib/graph_weaver/selection.rb +20 -3
- data/lib/graph_weaver/testing/cassette.rb +28 -3
- data/lib/graph_weaver/testing/fake_client.rb +18 -12
- data/lib/graph_weaver/transport/faraday.rb +7 -0
- data/lib/graph_weaver/transport.rb +8 -5
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +9 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f01ed46a3a7d500364530c522e28f5b1f70f9db803988f940e487630bca0f10c
|
|
4
|
+
data.tar.gz: ee2e237e936ca60054b6465f62e14714c359235abfab376fb3b14e232ef851f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bb3adc8c4d4f3164a34a650fe989c785d45559a78c52b10bc93abe21e4ab26c9fb3892b76694c563cbc62b2e342b2bd49b87cf2f7119afe9439facc8dc837e94
|
|
7
|
+
data.tar.gz: ae4521a62bb818a9d8da0053533c4749a56e021f88c343acd25123b04016176a942da9e824bcb41df32083ae1c28db6a81b32079490c15014940bb016d821be6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,46 @@
|
|
|
1
|
+
### v0.4.6 (2026-07-30)
|
|
2
|
+
Bug fixes from a full-library review (all with regression coverage):
|
|
3
|
+
- alias: a nested-object/enum leaf (`meta.sub`) now qualifies its constant
|
|
4
|
+
(`Meta::Sub`) instead of emitting a bare `Sub` that raised NameError; alias
|
|
5
|
+
names/segments are validated as identifiers (were interpolated verbatim,
|
|
6
|
+
allowing injection); `optional:` no longer swallows a reserved-name/collision
|
|
7
|
+
mistake; a real field named `first`/`last` resolves as a field.
|
|
8
|
+
- Shared unions: a hoisted member selecting a mapped enum now emits its
|
|
9
|
+
`<NAME>_FROM_WIRE` table into `unions.rb` (was a NameError at `from_h`); a
|
|
10
|
+
fragment whose name collides with `Result`/`QUERY` is refused.
|
|
11
|
+
- A named interface fragment holding inline `... on X` conditions now dispatches
|
|
12
|
+
instead of silently dropping those fields; fragment cycles raise a clear error
|
|
13
|
+
in the FakeClient/Anonymizer walkers instead of `SystemStackError`.
|
|
14
|
+
- Malformed responses/inputs stay under `GraphWeaver::Error`: a non-2xx
|
|
15
|
+
`errors: null` body keeps its status; a 2xx non-object body is a `ServerError`;
|
|
16
|
+
`data!` on null-data/no-errors raises `QueryError`; `coerce(non-Hash)` raises
|
|
17
|
+
`InputError`.
|
|
18
|
+
- Testing harness: FakeClient/Anonymizer merge duplicate result keys (were
|
|
19
|
+
fabricating shapes the generated struct couldn't cast); `fail_at` fires every
|
|
20
|
+
execute; symbol-keyed cassette variables no longer crash on reload; the
|
|
21
|
+
Anonymizer keeps concrete-fragment fields when data lacks `__typename`.
|
|
22
|
+
- Federation: a user type named `link` is no longer dropped; an all-`@inaccessible`
|
|
23
|
+
schema raises a pointed error. Client accepts `retries: nil` on a schema
|
|
24
|
+
source; `register_scalar` rejects an anonymous class.
|
|
25
|
+
- FakeClient: an Integer `list_size` now means exactly that length (a Range
|
|
26
|
+
randomizes within it). Codegen rejects two variables that underscore to the
|
|
27
|
+
same kwarg (`$userId` + `$user_id`). The Faraday transport raises rather than
|
|
28
|
+
silently dropping `headers:`/block when handed a prebuilt connection.
|
|
29
|
+
|
|
30
|
+
### v0.4.5 (2026-07-30)
|
|
31
|
+
- `alias:` paths gain list-element selectors: a `first`/`last` segment picks one
|
|
32
|
+
element out of a list hop, always nilable — `alias: { entity: "_entities.first" }`
|
|
33
|
+
yields `def entity = _entities&.first`, and navigation continues into the
|
|
34
|
+
element (`_entities.first.name`). Typed from the selection: a single inline
|
|
35
|
+
fragment lands on the concrete member (`T.nilable(Widget)`), a multi-fragment
|
|
36
|
+
selection on the union. Selectors are checked against the node shape — `.first`
|
|
37
|
+
on a non-list raises. Cleanly retires the `result._entities&.first&.field`
|
|
38
|
+
boilerplate of single-entity federation `_entities` queries.
|
|
39
|
+
- `extend_type(..., optional: true)` makes its aliases lenient: a query whose
|
|
40
|
+
selection doesn't fit the path omits the accessor instead of failing
|
|
41
|
+
generation. For an alias on a universal type (a `Query` accessor a strict alias
|
|
42
|
+
would force every query to satisfy), or one that only fits some selections.
|
|
43
|
+
|
|
1
44
|
### v0.4.4 (2026-07-30)
|
|
2
45
|
- Supergraph loading now derives the **API schema**: `@inaccessible` elements
|
|
3
46
|
(present in the federated graph but hidden from what the router serves) are
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
graph_weaver (0.4.
|
|
4
|
+
graph_weaver (0.4.6)
|
|
5
5
|
graphql (>= 2.6.7)
|
|
6
6
|
sorbet-runtime
|
|
7
7
|
|
|
@@ -176,7 +176,7 @@ CHECKSUMS
|
|
|
176
176
|
google-protobuf (4.35.1-arm64-darwin) sha256=d9c957df04fa89c749fa9a72a7b383eb4296efc9b2303dc6fd6fbe39c698ad6b
|
|
177
177
|
google-protobuf (4.35.1-x86_64-darwin) sha256=66b62b4df00931018a692806df66393efa960d6d2b7da69735187249f950d3ee
|
|
178
178
|
google-protobuf (4.35.1-x86_64-linux-gnu) sha256=c786439087512a3fbd199e9897d265b855f951d4027e218ea55e858d45969edd
|
|
179
|
-
graph_weaver (0.4.
|
|
179
|
+
graph_weaver (0.4.6)
|
|
180
180
|
graphql (2.6.7) sha256=759755ce5819c965b6459c5039605cf2e4d876bb8ba5e03451676870ea787c45
|
|
181
181
|
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
|
|
182
182
|
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
|
data/docs/scalars.md
CHANGED
|
@@ -232,8 +232,26 @@ alias: { label: "name", tag: "meta.tag" }
|
|
|
232
232
|
The path is the Ruby accessor chain (`meta.tag`), typed from the selection: any
|
|
233
233
|
nullable hop makes the accessor nilable and inserts `&.`; the leaf can be a
|
|
234
234
|
scalar, enum, or nested struct. It's validated against each query at generation —
|
|
235
|
-
an unselected or misspelled segment (`did you mean 'tag'?`), a
|
|
236
|
-
list, or a name that collides with a real field all fail with a pointed
|
|
237
|
-
Registrations stack and are client-scopable, like the mixin forms.
|
|
238
|
-
|
|
239
|
-
|
|
235
|
+
an unselected or misspelled segment (`did you mean 'tag'?`), a selector on a
|
|
236
|
+
non-list, or a name that collides with a real field all fail with a pointed
|
|
237
|
+
error. Registrations stack and are client-scopable, like the mixin forms.
|
|
238
|
+
|
|
239
|
+
A segment can also be `first` or `last` to pick one element out of a list hop —
|
|
240
|
+
always nilable, since the list may be empty. This is what turns an
|
|
241
|
+
`_entities`-style "array that logically holds one thing" into a clean accessor:
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
GraphWeaver.extend_type("Query", alias: { entity: "_entities.first" }, optional: true)
|
|
245
|
+
|
|
246
|
+
# sig { returns(T.nilable(Widget)) } # concrete, when the selection is one `... on Widget`
|
|
247
|
+
# def entity = _entities&.first # (a multi-fragment selection types it as the union)
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`optional: true` makes the aliases *lenient*: a query whose selection doesn't fit
|
|
251
|
+
the path just omits the accessor instead of failing generation. Reach for it when
|
|
252
|
+
the alias lives on a universal type like `Query` — where a strict alias would
|
|
253
|
+
force *every* query to select the path — or when it only fits some selections.
|
|
254
|
+
|
|
255
|
+
For anything beyond a passthrough projection — real logic, still typed — reopen
|
|
256
|
+
the generated struct in your own file and add sig'd methods; Sorbet merges the
|
|
257
|
+
bodies.
|
data/lib/graph_weaver/client.rb
CHANGED
|
@@ -35,7 +35,7 @@ class GraphWeaver::Client
|
|
|
35
35
|
|
|
36
36
|
@transport = wrap_retries(build_transport(source, auth:, headers:, &middleware), retries)
|
|
37
37
|
else
|
|
38
|
-
if auth || middleware || retries
|
|
38
|
+
if auth || middleware || retries
|
|
39
39
|
raise ArgumentError, "auth:/retries:/middleware apply to a url — got a schema source"
|
|
40
40
|
end
|
|
41
41
|
if cache || ttl
|
|
@@ -194,6 +194,13 @@ class GraphWeaver::Codegen
|
|
|
194
194
|
end
|
|
195
195
|
out << "module #{@module_name}"
|
|
196
196
|
out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
|
|
197
|
+
# a member selecting a mapped enum (register_enum) reads its module-level
|
|
198
|
+
# <NAME>_FROM_WIRE table — emit those here so from_h resolves them, the
|
|
199
|
+
# same way emit_variable_types does for the query module
|
|
200
|
+
@mapped_enums.each_value do |mapped|
|
|
201
|
+
emit_mapped_enum(mapped, out, 1)
|
|
202
|
+
out << ""
|
|
203
|
+
end
|
|
197
204
|
unions.each do |union|
|
|
198
205
|
emit_union(union, out, 1)
|
|
199
206
|
out << ""
|
|
@@ -110,30 +110,56 @@ class GraphWeaver::Codegen
|
|
|
110
110
|
# GraphWeaver.extend_type("Widget", alias: { tag: "meta.tag" })
|
|
111
111
|
# GraphWeaver.extend_type("Widget", alias: "meta.tag") # accessor named `tag`
|
|
112
112
|
# GraphWeaver.extend_type("Widget", alias: ["meta.tag", "meta.color"])
|
|
113
|
+
#
|
|
114
|
+
# A path segment is a field, or `first`/`last` to pick one element out of a
|
|
115
|
+
# list hop (always nilable): `alias: { entity: "_entities.first" }`.
|
|
116
|
+
#
|
|
117
|
+
# optional: true makes the aliases lenient — a query whose selection doesn't
|
|
118
|
+
# fit the path just omits the accessor instead of failing generation. Use it
|
|
119
|
+
# for a root-type accessor (a Query alias every query would otherwise have to
|
|
120
|
+
# satisfy) or one that only fits some selections.
|
|
113
121
|
def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
|
|
114
122
|
aliases = take_aliases(kw)
|
|
115
123
|
entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [], aliases: {} }
|
|
116
124
|
add_type_helpers(entry, graphql_name, mixins, requires, block, aliases)
|
|
117
125
|
end
|
|
118
126
|
|
|
119
|
-
# Pull alias: out of the keyword rest and normalize
|
|
120
|
-
# is a typo worth flagging rather than silently dropping.
|
|
127
|
+
# Pull alias:/optional: out of the keyword rest and normalize; any other
|
|
128
|
+
# keyword is a typo worth flagging rather than silently dropping.
|
|
121
129
|
def take_aliases(kw)
|
|
122
|
-
aliases = normalize_aliases(kw.delete(:alias))
|
|
130
|
+
aliases = normalize_aliases(kw.delete(:alias), optional: !!kw.delete(:optional))
|
|
123
131
|
raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
|
|
124
132
|
aliases
|
|
125
133
|
end
|
|
126
134
|
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
+
# accessor names and path segments are interpolated verbatim into generated
|
|
136
|
+
# source, so — like module_name — they must be plain identifiers, never
|
|
137
|
+
# arbitrary text that could inject code
|
|
138
|
+
ALIAS_NAME = /\A[a-zA-Z_]\w*[?!]?\z/
|
|
139
|
+
ALIAS_SEGMENT = /\A[a-zA-Z_]\w*\z/
|
|
140
|
+
|
|
141
|
+
# { accessor => { segments:, optional: } } from a path string (accessor
|
|
142
|
+
# named after the last segment), an array of such, or an { accessor => path }
|
|
143
|
+
# hash. `optional:` marks every alias in this registration as lenient.
|
|
144
|
+
def normalize_aliases(input, optional:)
|
|
145
|
+
pairs = case input
|
|
146
|
+
when nil then []
|
|
147
|
+
when String then [[input.split(".").last, input.split(".")]]
|
|
148
|
+
when Array then input.map { |path| [path.split(".").last, path.split(".")] }
|
|
149
|
+
when Hash then input.map { |name, path| [name.to_s, path.to_s.split(".")] }
|
|
135
150
|
else raise ArgumentError, "alias: expects a String, Array, or Hash, got #{input.class}"
|
|
136
151
|
end
|
|
152
|
+
pairs.to_h do |name, segments|
|
|
153
|
+
unless name.to_s.match?(ALIAS_NAME)
|
|
154
|
+
raise ArgumentError, "alias name #{name.inspect} is not a valid method name"
|
|
155
|
+
end
|
|
156
|
+
raise ArgumentError, "alias #{name.inspect} has an empty path" if segments.empty?
|
|
157
|
+
|
|
158
|
+
bad = segments.reject { |seg| seg.match?(ALIAS_SEGMENT) }
|
|
159
|
+
raise ArgumentError, "alias #{name.inspect} has an invalid path segment: #{bad.first.inspect}" if bad.any?
|
|
160
|
+
|
|
161
|
+
[name, { segments:, optional: }]
|
|
162
|
+
end
|
|
137
163
|
end
|
|
138
164
|
|
|
139
165
|
def type_registry
|
|
@@ -123,7 +123,10 @@ class GraphWeaver::Codegen
|
|
|
123
123
|
|
|
124
124
|
def type_name(type)
|
|
125
125
|
case type
|
|
126
|
-
when Module
|
|
126
|
+
when Module
|
|
127
|
+
# an anonymous class has no name to emit — it would land as a literal
|
|
128
|
+
# `nil` in generated source
|
|
129
|
+
type.name || raise(ArgumentError, "type: must be a named class/module, got an anonymous one")
|
|
127
130
|
when String then type
|
|
128
131
|
else raise ArgumentError, "type: must be a class/module or String, got #{type.inspect}"
|
|
129
132
|
end
|
data/lib/graph_weaver/codegen.rb
CHANGED
|
@@ -175,14 +175,26 @@ class GraphWeaver::Codegen
|
|
|
175
175
|
@fragments = fragments
|
|
176
176
|
|
|
177
177
|
unions = names.uniq.sort.map do |name|
|
|
178
|
+
class_name = camelize(name)
|
|
179
|
+
# the query module aliases <class_name> = <unions module>::<class_name>;
|
|
180
|
+
# a name that camelizes to a generated module-level constant (the Result
|
|
181
|
+
# struct, the QUERY heredoc) would collide with that alias at load
|
|
182
|
+
if HOISTED_UNION_RESERVED.include?(class_name)
|
|
183
|
+
raise GraphWeaver::Error,
|
|
184
|
+
"shared fragment #{name.inspect} hoists to #{class_name}, which collides with a generated constant — rename the fragment"
|
|
185
|
+
end
|
|
178
186
|
fragment = fragments.fetch(name)
|
|
179
187
|
type = @schema.get_type(fragment.type.name)
|
|
180
|
-
UnionNode.new(
|
|
188
|
+
UnionNode.new(class_name, union_members(type, fragment.selections))
|
|
181
189
|
end
|
|
182
190
|
|
|
183
191
|
emit_unions_file(unions)
|
|
184
192
|
end
|
|
185
193
|
|
|
194
|
+
# module-level constants every generated query module defines — a hoisted
|
|
195
|
+
# union aliased to one of these would clash at load
|
|
196
|
+
HOISTED_UNION_RESERVED = %w[Result QUERY].to_set.freeze
|
|
197
|
+
|
|
186
198
|
VarDef = Struct.new(:kwarg, :wire, :node, :required)
|
|
187
199
|
|
|
188
200
|
# Names that cannot appear bare in generated Ruby: keywords aren't
|
|
@@ -246,6 +258,15 @@ class GraphWeaver::Codegen
|
|
|
246
258
|
VarDef.new(kwarg, var.name, node, required)
|
|
247
259
|
end
|
|
248
260
|
|
|
261
|
+
# two variables that underscore to the same kwarg ($userId + $user_id) would
|
|
262
|
+
# silently drop one on the wire — flag it like a prop collision
|
|
263
|
+
collision = variables.group_by(&:kwarg).find { |_, vars| vars.size > 1 }
|
|
264
|
+
if collision
|
|
265
|
+
wire = collision.last.map { |var| "$#{var.wire}" }.join(", ")
|
|
266
|
+
raise GraphWeaver::Error,
|
|
267
|
+
"variables #{wire} both map to the kwarg '#{collision.first}:' — rename one"
|
|
268
|
+
end
|
|
269
|
+
|
|
249
270
|
root = object_node(root_type, operation.selections, "Result")
|
|
250
271
|
|
|
251
272
|
emit_module(root, variables)
|
|
@@ -270,8 +291,7 @@ class GraphWeaver::Codegen
|
|
|
270
291
|
|
|
271
292
|
return if schema.get_type(name)
|
|
272
293
|
|
|
273
|
-
suggestion =
|
|
274
|
-
DidYouMean::SpellChecker.new(dictionary: schema.types.keys).correct(name).first
|
|
294
|
+
suggestion = GraphWeaver.did_you_mean(schema.types.keys, name)
|
|
275
295
|
hint = suggestion ? " — did you mean '#{suggestion}'?" : ""
|
|
276
296
|
# the type registry is reached via extend_type; scalars/enums via register_*
|
|
277
297
|
method = kind == "type" ? "extend_type" : "register_#{kind}"
|
|
@@ -367,14 +387,6 @@ class GraphWeaver::Codegen
|
|
|
367
387
|
end
|
|
368
388
|
|
|
369
389
|
|
|
370
|
-
# Selection#each_field, collected by result key (codegen groups
|
|
371
|
-
# repeated selections of one field so it can merge them)
|
|
372
|
-
def gather(type, selections)
|
|
373
|
-
out = {}
|
|
374
|
-
each_field(type, selections) { |key, node| (out[key] ||= []) << node }
|
|
375
|
-
out
|
|
376
|
-
end
|
|
377
|
-
|
|
378
390
|
def object_node(type, selections, class_name)
|
|
379
391
|
node = ObjectNode.new(class_name)
|
|
380
392
|
node.graphql_type = type.graphql_name
|
|
@@ -474,8 +486,25 @@ class GraphWeaver::Codegen
|
|
|
474
486
|
# struct body. Validated here, per query, so an unselected or untraversable
|
|
475
487
|
# path fails at generation with a pointed message.
|
|
476
488
|
def resolve_aliases(node)
|
|
477
|
-
type_aliases(node.graphql_type).
|
|
478
|
-
|
|
489
|
+
type_aliases(node.graphql_type).filter_map do |name, spec|
|
|
490
|
+
# a bad accessor name (reserved, or colliding with a real field) is a
|
|
491
|
+
# registration mistake — it fails for every query, so it always raises,
|
|
492
|
+
# even for optional aliases (which otherwise mask it as "doesn't fit").
|
|
493
|
+
check_alias_name!(node, name)
|
|
494
|
+
begin
|
|
495
|
+
resolve_alias(node, name, spec[:segments])
|
|
496
|
+
rescue GraphWeaver::Error
|
|
497
|
+
# a path that doesn't fit THIS query's selection: strict raises,
|
|
498
|
+
# optional simply omits the accessor
|
|
499
|
+
raise unless spec[:optional]
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def check_alias_name!(node, name)
|
|
505
|
+
if node.fields.any? { |f| f.prop == name } || ALIAS_RESERVED.include?(name)
|
|
506
|
+
raise GraphWeaver::Error,
|
|
507
|
+
"alias #{name.inspect} on #{node.graphql_type} collides with an existing field or method"
|
|
479
508
|
end
|
|
480
509
|
end
|
|
481
510
|
|
|
@@ -487,61 +516,126 @@ class GraphWeaver::Codegen
|
|
|
487
516
|
end
|
|
488
517
|
|
|
489
518
|
ALIAS_RESERVED = (%w[from_h serialize to_h].to_set + RUBY_KEYWORDS).freeze
|
|
490
|
-
|
|
491
|
-
#
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
#
|
|
519
|
+
# list selectors — pick one element out of a list-typed hop, always nilable
|
|
520
|
+
# (the list may be empty). Everything else is a field prop.
|
|
521
|
+
LIST_SELECTORS = %w[first last].freeze
|
|
522
|
+
|
|
523
|
+
# Walk a dotted path through this struct's selected shape, building the
|
|
524
|
+
# delegator expression (`meta&.tag`, `_entities.first&.name`) and its return
|
|
525
|
+
# type. A segment is a field prop, or `first`/`last` to pick a list element.
|
|
526
|
+
# Everything is checked against the node tree: a field on a non-object, a
|
|
527
|
+
# selector on a non-list, or an unselected segment raises. Any nilable hop
|
|
528
|
+
# (a nullable field, or a list element) makes the accessor nilable.
|
|
495
529
|
def resolve_alias(node, name, segments)
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
530
|
+
cur = T.let(node, T.untyped) # the node the path has reached
|
|
531
|
+
cur_nilable = T.let(false, T::Boolean) # is the expression so far nilable
|
|
532
|
+
nilable = T.let(false, T::Boolean) # is the accessor overall nilable
|
|
533
|
+
containers = T.let([], T::Array[String]) # nested-struct class names on the way to the leaf
|
|
534
|
+
expr = +""
|
|
535
|
+
|
|
536
|
+
segments.each do |seg|
|
|
537
|
+
connector = expr.empty? ? "" : (cur_nilable ? "&." : ".")
|
|
538
|
+
|
|
539
|
+
# `first`/`last` select an element only when the current hop is actually a
|
|
540
|
+
# list; otherwise they're an ordinary field (a schema field named `first`)
|
|
541
|
+
if LIST_SELECTORS.include?(seg) && list_of(cur)
|
|
542
|
+
expr << connector << seg
|
|
543
|
+
cur = list_of(cur).of
|
|
544
|
+
cur_nilable = true # first/last is nil on an empty list
|
|
545
|
+
nilable = true
|
|
546
|
+
else
|
|
547
|
+
obj = object_of(cur)
|
|
548
|
+
unless obj
|
|
549
|
+
hint = if list_of(cur)
|
|
550
|
+
" — use .first or .last to pick an element"
|
|
551
|
+
elsif LIST_SELECTORS.include?(seg)
|
|
552
|
+
" — .#{seg} needs a list"
|
|
553
|
+
else
|
|
554
|
+
""
|
|
555
|
+
end
|
|
556
|
+
raise GraphWeaver::Error,
|
|
557
|
+
"alias #{name.inspect} on #{node.graphql_type}: '#{seg}' can't be read here (not an object)#{hint}"
|
|
558
|
+
end
|
|
559
|
+
# the object a field is read from is the lexical container of its result
|
|
560
|
+
# (nested structs emit inside their parent); the aliased struct itself is
|
|
561
|
+
# the delegator's own scope, so it contributes no prefix
|
|
562
|
+
containers << obj.class_name unless obj.equal?(node)
|
|
563
|
+
field = obj.fields.find { |f| f.prop == seg }
|
|
564
|
+
unless field
|
|
565
|
+
props = obj.fields.map(&:prop)
|
|
566
|
+
suggestion = GraphWeaver.did_you_mean(props, seg)
|
|
567
|
+
hint = suggestion ? " — did you mean '#{suggestion}'?" : " (have: #{props.join(", ")})"
|
|
568
|
+
raise GraphWeaver::Error,
|
|
569
|
+
"alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a selected field#{hint}"
|
|
570
|
+
end
|
|
571
|
+
expr << connector << seg
|
|
572
|
+
cur = field.node
|
|
573
|
+
cur_nilable = !field.node.non_null?
|
|
574
|
+
nilable ||= cur_nilable
|
|
575
|
+
end
|
|
499
576
|
end
|
|
500
577
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
field = current.fields.find { |f| f.prop == seg }
|
|
506
|
-
unless field
|
|
507
|
-
props = current.fields.map(&:prop)
|
|
508
|
-
suggestion = defined?(DidYouMean::SpellChecker) &&
|
|
509
|
-
DidYouMean::SpellChecker.new(dictionary: props).correct(seg).first
|
|
510
|
-
hint = suggestion ? " — did you mean '#{suggestion}'?" : " (have: #{props.join(", ")})"
|
|
511
|
-
raise GraphWeaver::Error,
|
|
512
|
-
"alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a selected field#{hint}"
|
|
513
|
-
end
|
|
514
|
-
nilable ||= !field.node.non_null?
|
|
578
|
+
leaf = qualified_alias_type(cur, containers)
|
|
579
|
+
type = nilable && leaf != "T.untyped" ? "T.nilable(#{leaf})" : leaf
|
|
580
|
+
ObjectNode::Alias.new(name, expr, type)
|
|
581
|
+
end
|
|
515
582
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
583
|
+
# The leaf's Sorbet type as referenced from the aliased struct. Generated
|
|
584
|
+
# nested constants (structs, enums, unions) must carry the container path,
|
|
585
|
+
# since the delegator's `sig` is emitted in an outer struct where a bare
|
|
586
|
+
# `Sub` wouldn't resolve; scalars, mapped enums, and hoisted union refs are
|
|
587
|
+
# already top-level. `containers` is the class-name chain to the leaf.
|
|
588
|
+
def qualified_alias_type(node, containers)
|
|
589
|
+
node = node.of if node.is_a?(NonNull)
|
|
590
|
+
prefix = containers.empty? ? "" : "#{containers.join("::")}::"
|
|
521
591
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
592
|
+
case node
|
|
593
|
+
when List
|
|
594
|
+
element = node.of.is_a?(NonNull) ? qualified_alias_type(node.of, containers) : begin
|
|
595
|
+
inner = qualified_alias_type(node.of, containers)
|
|
596
|
+
inner == "T.untyped" ? inner : "T.nilable(#{inner})"
|
|
527
597
|
end
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
598
|
+
"T::Array[#{element}]"
|
|
599
|
+
when ObjectNode, EnumNode, NarrowedNode then "#{prefix}#{node.class_name}"
|
|
600
|
+
when UnionNode then "#{prefix}#{node.bare_type}"
|
|
601
|
+
else node.bare_type # Scalar, MappedEnum, UnionRefNode — already top-level
|
|
531
602
|
end
|
|
532
603
|
end
|
|
533
604
|
|
|
534
|
-
#
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
605
|
+
# the List a node wraps (through NON_NULL), or nil
|
|
606
|
+
def list_of(node)
|
|
607
|
+
node = T.let(node, T.untyped)
|
|
608
|
+
node = node.of while node.is_a?(NonNull)
|
|
609
|
+
node if node.is_a?(List)
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# the ObjectNode a node resolves to for field access (through NON_NULL and a
|
|
613
|
+
# narrowed abstract member), or nil — unions/scalars/lists can't be read into
|
|
614
|
+
def object_of(node)
|
|
615
|
+
node = T.let(node, T.untyped)
|
|
616
|
+
node = node.of while node.is_a?(NonNull)
|
|
617
|
+
node = node.nested if node.is_a?(NarrowedNode)
|
|
618
|
+
node if node.is_a?(ObjectNode)
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# The concrete type conditions a selection mentions, minus conditions naming
|
|
622
|
+
# the abstract type itself — recursing into named-fragment and inline bodies,
|
|
623
|
+
# so a `... on X` nested inside a spread (`{ ...NodeFields }` where NodeFields
|
|
624
|
+
# holds `... on X`) still drives dispatch instead of being silently dropped.
|
|
625
|
+
def concrete_conditions(core, selections, visiting = Set.new)
|
|
626
|
+
selections.flat_map do |selection|
|
|
538
627
|
case selection
|
|
539
628
|
when GraphQL::Language::Nodes::InlineFragment
|
|
540
|
-
selection.type&.name
|
|
629
|
+
[selection.type&.name, *concrete_conditions(core, selection.selections, visiting)]
|
|
541
630
|
when GraphQL::Language::Nodes::FragmentSpread
|
|
542
|
-
|
|
631
|
+
next [] if visiting.include?(selection.name)
|
|
632
|
+
|
|
633
|
+
fragment = @fragments.fetch(selection.name)
|
|
634
|
+
[fragment.type.name, *concrete_conditions(core, fragment.selections, visiting | [selection.name])]
|
|
635
|
+
else
|
|
636
|
+
[]
|
|
543
637
|
end
|
|
544
|
-
end.uniq - [core.graphql_name]
|
|
638
|
+
end.compact.uniq - [core.graphql_name]
|
|
545
639
|
end
|
|
546
640
|
|
|
547
641
|
# result keys selected as plain fields (outside any type condition)
|
data/lib/graph_weaver/hints.rb
CHANGED
|
@@ -28,8 +28,8 @@ module GraphWeaver
|
|
|
28
28
|
prop = GraphWeaver::Inflect.underscore(key)
|
|
29
29
|
suggestion = if known.include?(prop)
|
|
30
30
|
prop # a wire-cased key — the exact snake_case prop exists
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
else
|
|
32
|
+
GraphWeaver.did_you_mean(known, prop)
|
|
33
33
|
end
|
|
34
34
|
suggestion ? "#{key} (did you mean '#{suggestion}'?)" : key
|
|
35
35
|
end
|
|
@@ -56,12 +56,10 @@ module GraphWeaver
|
|
|
56
56
|
return "GraphQL fields generate snake_case props; use '#{prop}'"
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
return unless defined?(DidYouMean::SpellChecker)
|
|
60
|
-
|
|
61
59
|
# a guess, not a mapping — spellcheck the (underscored) miss
|
|
62
60
|
# against the props that exist, so typos in either casing land
|
|
63
61
|
props = T.unsafe(self.class).props.keys.map(&:to_s)
|
|
64
|
-
suggestion =
|
|
62
|
+
suggestion = GraphWeaver.did_you_mean(props, prop)
|
|
65
63
|
"did you mean '#{suggestion}'?" if suggestion
|
|
66
64
|
end
|
|
67
65
|
end
|
|
@@ -46,6 +46,13 @@ module GraphWeaver
|
|
|
46
46
|
def coerce(value)
|
|
47
47
|
return value if value.is_a?(self)
|
|
48
48
|
|
|
49
|
+
# a caller passing a non-Hash (a bare string, or a Hash where a nested
|
|
50
|
+
# list was expected) is bad input — surface a branded 422, not a raw
|
|
51
|
+
# NoMethodError from validate_keys!'s `.keys`
|
|
52
|
+
unless value.is_a?(Hash)
|
|
53
|
+
raise GraphWeaver::InputError.new("expected a Hash or #{self}, got #{value.class}", struct: self)
|
|
54
|
+
end
|
|
55
|
+
|
|
49
56
|
# a typo'd key must not silently drop off the wire
|
|
50
57
|
GraphWeaver::Hints.validate_keys!(self, value)
|
|
51
58
|
|
|
@@ -49,7 +49,14 @@ module GraphWeaver
|
|
|
49
49
|
sig { returns(Data) }
|
|
50
50
|
def data!
|
|
51
51
|
raise GraphWeaver::QueryError.new(errors, data: data, extensions: extensions) unless errors.empty?
|
|
52
|
-
|
|
52
|
+
|
|
53
|
+
# a well-formed GraphQL response always pairs null data with errors; a
|
|
54
|
+
# server (or an errors-stripping proxy) that returns neither is broken —
|
|
55
|
+
# brand it rather than leaking a bare `T.must` TypeError
|
|
56
|
+
data || raise(GraphWeaver::QueryError.new(
|
|
57
|
+
[GraphWeaver::GraphQLError.new(message: "response carried neither data nor errors")],
|
|
58
|
+
extensions: extensions,
|
|
59
|
+
))
|
|
53
60
|
end
|
|
54
61
|
end
|
|
55
62
|
end
|
|
@@ -58,11 +58,18 @@ module GraphWeaver::SchemaLoader
|
|
|
58
58
|
FEDERATION_PREFIXES = %w[join__ link__ core__].freeze
|
|
59
59
|
FEDERATION_DIRECTIVES = %w[link core inaccessible].to_set.freeze
|
|
60
60
|
|
|
61
|
-
#
|
|
62
|
-
|
|
61
|
+
# Synthetic composition TYPES are always prefixed (join__Graph, link__Import).
|
|
62
|
+
# The bare names (link/core/inaccessible) are DIRECTIVES only — a user type
|
|
63
|
+
# literally named `link` (Hasura-style lowercase) must not be dropped.
|
|
64
|
+
def self.federation_type_name?(name)
|
|
65
|
+
!!name && name.start_with?(*FEDERATION_PREFIXES)
|
|
66
|
+
end
|
|
67
|
+
private_class_method :federation_type_name?
|
|
68
|
+
|
|
69
|
+
def self.federation_directive_name?(name)
|
|
63
70
|
!!name && (name.start_with?(*FEDERATION_PREFIXES) || FEDERATION_DIRECTIVES.include?(name))
|
|
64
71
|
end
|
|
65
|
-
private_class_method :
|
|
72
|
+
private_class_method :federation_directive_name?
|
|
66
73
|
|
|
67
74
|
# Drop the composition machinery from supergraph SDL: the synthetic
|
|
68
75
|
# join__*/link__* type and directive definitions, and every @join__*/@link
|
|
@@ -74,11 +81,31 @@ module GraphWeaver::SchemaLoader
|
|
|
74
81
|
def self.strip_federation(sdl)
|
|
75
82
|
doc = GraphQL.parse(sdl)
|
|
76
83
|
defs = remove_inaccessible(doc.definitions)
|
|
77
|
-
.reject { |defn|
|
|
84
|
+
.reject { |defn| federation_definition?(defn) }
|
|
78
85
|
.map { |defn| strip_federation_directives(defn) }
|
|
86
|
+
|
|
87
|
+
if defs.none? { |d| d.is_a?(GraphQL::Language::Nodes::ObjectTypeDefinition) }
|
|
88
|
+
raise GraphWeaver::Error,
|
|
89
|
+
"supergraph has no object types left after stripping the federation machinery — " \
|
|
90
|
+
"is the whole schema behind @inaccessible?"
|
|
91
|
+
end
|
|
92
|
+
|
|
79
93
|
GraphQL::Language::Nodes::Document.new(definitions: defs).to_query_string
|
|
80
94
|
end
|
|
81
95
|
|
|
96
|
+
# a synthetic composition definition to drop: a federation directive
|
|
97
|
+
# definition (by name), or a synthetic join__*/link__* type (by prefix)
|
|
98
|
+
def self.federation_definition?(defn)
|
|
99
|
+
return false unless defn.respond_to?(:name)
|
|
100
|
+
|
|
101
|
+
if defn.is_a?(GraphQL::Language::Nodes::DirectiveDefinition)
|
|
102
|
+
federation_directive_name?(defn.name)
|
|
103
|
+
else
|
|
104
|
+
federation_type_name?(defn.name)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
private_class_method :federation_definition?
|
|
108
|
+
|
|
82
109
|
# Derive the API schema by dropping every element marked @inaccessible —
|
|
83
110
|
# present in the federated graph but hidden from the public API the router
|
|
84
111
|
# serves (its common use is safely rolling out a field on a shared type).
|
|
@@ -93,8 +120,9 @@ module GraphWeaver::SchemaLoader
|
|
|
93
120
|
survivors = definitions
|
|
94
121
|
.reject { |d| type_definition?(d) && removed.include?(d.name) }
|
|
95
122
|
.map { |d| prune_inaccessible(d, removed) }
|
|
123
|
+
# survivors already exclude `removed`, so anything newly emptied is fresh
|
|
96
124
|
newly = survivors.select { |d| type_definition?(d) && type_emptied?(d) }.map(&:name)
|
|
97
|
-
return survivors if
|
|
125
|
+
return survivors if newly.empty?
|
|
98
126
|
|
|
99
127
|
removed.merge(newly)
|
|
100
128
|
end
|
|
@@ -162,7 +190,7 @@ module GraphWeaver::SchemaLoader
|
|
|
162
190
|
def self.strip_federation_directives(node)
|
|
163
191
|
changes = {}
|
|
164
192
|
if node.respond_to?(:directives) && node.directives
|
|
165
|
-
changes[:directives] = node.directives.reject { |d|
|
|
193
|
+
changes[:directives] = node.directives.reject { |d| federation_directive_name?(d.name) }
|
|
166
194
|
end
|
|
167
195
|
changes[:fields] = node.fields.map { |c| strip_federation_directives(c) } if node.respond_to?(:fields) && node.fields
|
|
168
196
|
changes[:arguments] = node.arguments.map { |c| strip_federation_directives(c) } if node.respond_to?(:arguments) && node.arguments
|
|
@@ -34,24 +34,41 @@ module GraphWeaver
|
|
|
34
34
|
# Flatten a selection set as seen by `type`, yielding (result_key,
|
|
35
35
|
# field_node) per field: plain fields yield directly; inline fragments
|
|
36
36
|
# and named spreads recurse when their type condition applies.
|
|
37
|
-
def each_field(type, selections, &block)
|
|
37
|
+
def each_field(type, selections, visiting = Set.new, &block)
|
|
38
38
|
selections.each do |selection|
|
|
39
39
|
case selection
|
|
40
40
|
when GraphQL::Language::Nodes::Field
|
|
41
41
|
yield(selection.alias || selection.name, selection)
|
|
42
42
|
when GraphQL::Language::Nodes::InlineFragment
|
|
43
|
-
each_field(type, selection.selections, &block) if applies?(selection.type&.name, type)
|
|
43
|
+
each_field(type, selection.selections, visiting, &block) if applies?(selection.type&.name, type)
|
|
44
44
|
when GraphQL::Language::Nodes::FragmentSpread
|
|
45
45
|
fragment = @fragments.fetch(selection.name) do
|
|
46
46
|
raise ArgumentError, "unknown fragment: #{selection.name}"
|
|
47
47
|
end
|
|
48
|
-
|
|
48
|
+
if visiting.include?(selection.name)
|
|
49
|
+
raise GraphWeaver::Error, "fragment cycle through #{selection.name}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if applies?(fragment.type.name, type)
|
|
53
|
+
each_field(type, fragment.selections, visiting | [selection.name], &block)
|
|
54
|
+
end
|
|
49
55
|
else
|
|
50
56
|
raise GraphWeaver::Error, "unsupported selection: #{selection.class}"
|
|
51
57
|
end
|
|
52
58
|
end
|
|
53
59
|
end
|
|
54
60
|
|
|
61
|
+
# each_field grouped by result key: repeated selections of one field
|
|
62
|
+
# (`a { x } a { y }`, or the same field reached through two fragments)
|
|
63
|
+
# collect together, so callers MERGE their sub-selections rather than
|
|
64
|
+
# last-writer-wins. Codegen relies on this; FakeClient/Anonymizer must too,
|
|
65
|
+
# or they'd fabricate/keep a shape the generated struct can't cast.
|
|
66
|
+
def gather(type, selections)
|
|
67
|
+
out = {}
|
|
68
|
+
each_field(type, selections) { |key, node| (out[key] ||= []) << node }
|
|
69
|
+
out
|
|
70
|
+
end
|
|
71
|
+
|
|
55
72
|
# A fragment's type condition applies when it names this type exactly,
|
|
56
73
|
# or an interface/union this type belongs to (`... on Named { ... }`).
|
|
57
74
|
def applies?(condition, type)
|
|
@@ -70,7 +70,7 @@ module GraphWeaver
|
|
|
70
70
|
entry = {
|
|
71
71
|
"key" => self.class.key(query, variables),
|
|
72
72
|
"query" => query,
|
|
73
|
-
"variables" => variables,
|
|
73
|
+
"variables" => self.class.normalize_variables(variables),
|
|
74
74
|
"response" => response,
|
|
75
75
|
}
|
|
76
76
|
@entries.reject! { |existing| existing["key"] == entry["key"] }
|
|
@@ -92,7 +92,14 @@ module GraphWeaver
|
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
def self.key(query, variables)
|
|
95
|
-
{ "query" => query.gsub(/\s+/, " ").strip, "variables" => variables
|
|
95
|
+
{ "query" => query.gsub(/\s+/, " ").strip, "variables" => normalize_variables(variables) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# JSON round-trip so symbol keys become strings — otherwise YAML.dump
|
|
99
|
+
# writes Ruby symbols the safe loader rejects on the next run, and lookup
|
|
100
|
+
# keys stay stable across processes
|
|
101
|
+
def self.normalize_variables(variables)
|
|
102
|
+
JSON.parse(JSON.generate(variables || {}))
|
|
96
103
|
end
|
|
97
104
|
|
|
98
105
|
private
|
|
@@ -167,6 +174,19 @@ module GraphWeaver
|
|
|
167
174
|
|
|
168
175
|
private
|
|
169
176
|
|
|
177
|
+
# Anonymization walks recorded data, not a live dispatch. When the query
|
|
178
|
+
# narrows an abstract type without selecting __typename (`named { name
|
|
179
|
+
# ... on Pet { species } }`), the recorded data has no type tag, so the
|
|
180
|
+
# strict applies? would drop the `... on Pet` fields. Treat any concrete
|
|
181
|
+
# member condition as applying; object_value's `data.key?(key)` guard
|
|
182
|
+
# discards fields the actual member's response didn't carry.
|
|
183
|
+
def applies?(condition, type)
|
|
184
|
+
return true if super
|
|
185
|
+
|
|
186
|
+
member = @schema.get_type(condition)
|
|
187
|
+
!!member && @schema.possible_types(type).include?(member)
|
|
188
|
+
end
|
|
189
|
+
|
|
170
190
|
def object_value(type, selections, data)
|
|
171
191
|
return data if data.nil?
|
|
172
192
|
|
|
@@ -190,7 +210,12 @@ module GraphWeaver
|
|
|
190
210
|
end
|
|
191
211
|
|
|
192
212
|
def field_value(parent_type, node, value)
|
|
193
|
-
|
|
213
|
+
# a field from a `... on Member` fragment lives on the member, not the
|
|
214
|
+
# abstract type we're walking (no __typename to narrow by), so fall back
|
|
215
|
+
# to whichever possible type declares it
|
|
216
|
+
field = @schema.get_field(parent_type.graphql_name, node.name) ||
|
|
217
|
+
@schema.possible_types(parent_type).filter_map { |t| @schema.get_field(t.graphql_name, node.name) }.first
|
|
218
|
+
type_value(field.type, node, value)
|
|
194
219
|
end
|
|
195
220
|
|
|
196
221
|
def type_value(type, node, value)
|
|
@@ -73,6 +73,8 @@ class GraphWeaver::Testing::FakeClient
|
|
|
73
73
|
|
|
74
74
|
@path = []
|
|
75
75
|
@failures = []
|
|
76
|
+
# fail_at fires once per execute, not once per client lifetime
|
|
77
|
+
@fail_at.each { |spec| spec.delete("triggered") }
|
|
76
78
|
data = object_value(root_type, operation.selections)
|
|
77
79
|
data = nil if data.equal?(NULL_BUBBLE) # total propagation, like a real server
|
|
78
80
|
|
|
@@ -104,9 +106,12 @@ class GraphWeaver::Testing::FakeClient
|
|
|
104
106
|
|
|
105
107
|
def object_value(type, selections)
|
|
106
108
|
result = {}
|
|
107
|
-
|
|
109
|
+
# gather (not each_field) so a field selected twice — `a { x } a { y }` —
|
|
110
|
+
# fabricates the MERGED shape codegen's struct expects, not last-writer-wins
|
|
111
|
+
gather(type, selections).each do |key, nodes|
|
|
112
|
+
node = nodes.first
|
|
108
113
|
@path.push(key)
|
|
109
|
-
value = node.name == "__typename" ? type.graphql_name : field_value(type, node)
|
|
114
|
+
value = node.name == "__typename" ? type.graphql_name : field_value(type, node, nodes.flat_map(&:selections))
|
|
110
115
|
@path.pop
|
|
111
116
|
|
|
112
117
|
if value.equal?(NULL_BUBBLE)
|
|
@@ -127,7 +132,7 @@ class GraphWeaver::Testing::FakeClient
|
|
|
127
132
|
@schema.get_field(type.graphql_name, node.name).type.kind.name == "NON_NULL"
|
|
128
133
|
end
|
|
129
134
|
|
|
130
|
-
def field_value(parent_type, node)
|
|
135
|
+
def field_value(parent_type, node, selections)
|
|
131
136
|
if (spec = matching_failure)
|
|
132
137
|
@failures << {
|
|
133
138
|
"message" => spec["message"] || "simulated failure",
|
|
@@ -148,7 +153,7 @@ class GraphWeaver::Testing::FakeClient
|
|
|
148
153
|
return corrupt_value(field_type)
|
|
149
154
|
end
|
|
150
155
|
|
|
151
|
-
type_value(field_type, node)
|
|
156
|
+
type_value(field_type, node, selections)
|
|
152
157
|
end
|
|
153
158
|
|
|
154
159
|
# a value casting can't accept, derived from the field's own type — and
|
|
@@ -181,17 +186,18 @@ class GraphWeaver::Testing::FakeClient
|
|
|
181
186
|
argument = node.arguments.find { |arg| %w[first last limit].include?(arg.name) }
|
|
182
187
|
return argument.value if argument && argument.value.is_a?(Integer)
|
|
183
188
|
|
|
184
|
-
|
|
189
|
+
# an Integer list_size means exactly that many; a Range randomizes within it
|
|
190
|
+
@list_size.is_a?(Range) ? rng.rand(@list_size) : @list_size
|
|
185
191
|
end
|
|
186
192
|
|
|
187
|
-
def type_value(type, node, non_null: false)
|
|
193
|
+
def type_value(type, node, selections, non_null: false)
|
|
188
194
|
case type.kind.name
|
|
189
195
|
when "NON_NULL"
|
|
190
|
-
type_value(type.of_type, node, non_null: true)
|
|
196
|
+
type_value(type.of_type, node, selections, non_null: true)
|
|
191
197
|
when "LIST"
|
|
192
198
|
elements = Array.new(list_length(node)) do |index|
|
|
193
199
|
@path.push(index)
|
|
194
|
-
element = type_value(type.of_type, node)
|
|
200
|
+
element = type_value(type.of_type, node, selections)
|
|
195
201
|
@path.pop
|
|
196
202
|
element
|
|
197
203
|
end
|
|
@@ -206,21 +212,21 @@ class GraphWeaver::Testing::FakeClient
|
|
|
206
212
|
else
|
|
207
213
|
return if !non_null && rng.rand < @null_chance
|
|
208
214
|
|
|
209
|
-
core_value(type, node)
|
|
215
|
+
core_value(type, node, selections)
|
|
210
216
|
end
|
|
211
217
|
end
|
|
212
218
|
|
|
213
|
-
def core_value(type, node)
|
|
219
|
+
def core_value(type, node, selections)
|
|
214
220
|
case type.kind.name
|
|
215
221
|
when "SCALAR"
|
|
216
222
|
@values.scalar(type.graphql_name, node.name)
|
|
217
223
|
when "ENUM"
|
|
218
224
|
type.values.keys.sort.sample(random: rng)
|
|
219
225
|
when "OBJECT"
|
|
220
|
-
object_value(type,
|
|
226
|
+
object_value(type, selections)
|
|
221
227
|
when "UNION", "INTERFACE"
|
|
222
228
|
member = @schema.possible_types(type).sort_by(&:graphql_name).sample(random: rng)
|
|
223
|
-
object_value(member,
|
|
229
|
+
object_value(member, selections)
|
|
224
230
|
else
|
|
225
231
|
raise NotImplementedError, "cannot fake kind: #{type.kind.name}"
|
|
226
232
|
end
|
|
@@ -32,6 +32,13 @@ module GraphWeaver
|
|
|
32
32
|
def initialize(url_or_connection, headers: {}, &block)
|
|
33
33
|
@connection = case url_or_connection
|
|
34
34
|
when ::Faraday::Connection
|
|
35
|
+
# a prebuilt connection carries its own headers/middleware, so
|
|
36
|
+
# headers:/block would be silently dropped — fail loudly instead
|
|
37
|
+
unless headers.empty? && block.nil?
|
|
38
|
+
raise ArgumentError,
|
|
39
|
+
"headers:/block are ignored when passing a prebuilt Faraday::Connection — configure them on it"
|
|
40
|
+
end
|
|
41
|
+
|
|
35
42
|
url_or_connection
|
|
36
43
|
else
|
|
37
44
|
# Faraday appends the default adapter when the block doesn't set one
|
|
@@ -62,15 +62,18 @@ class GraphWeaver::Transport
|
|
|
62
62
|
# envelope so QueryError machinery sees the structured errors; only
|
|
63
63
|
# a body that isn't GraphQL (proxy pages, HTML 500s) is a ServerError.
|
|
64
64
|
unless (200..299).cover?(status)
|
|
65
|
-
|
|
65
|
+
# only a body carrying actual GraphQL errors flows through — a 4xx with
|
|
66
|
+
# `"errors": null` (or []) isn't a structured error response, so the
|
|
67
|
+
# status stays the signal
|
|
68
|
+
return parsed if parsed.is_a?(Hash) && parsed["errors"].is_a?(Array) && parsed["errors"].any?
|
|
66
69
|
|
|
67
70
|
raise GraphWeaver::ServerError.new(status:, body: body.to_s)
|
|
68
71
|
end
|
|
69
72
|
|
|
70
|
-
unless parsed
|
|
71
|
-
# a 200 that isn't GraphQL — an HTML error page from a proxy, a
|
|
72
|
-
# captive portal: the server misbehaved
|
|
73
|
-
raise GraphWeaver::ServerError.new(status:, body: "non-
|
|
73
|
+
unless parsed.is_a?(Hash)
|
|
74
|
+
# a 200 that isn't a GraphQL object — an HTML error page from a proxy, a
|
|
75
|
+
# captive portal, or a bare JSON array/string: the server misbehaved
|
|
76
|
+
raise GraphWeaver::ServerError.new(status:, body: "non-GraphQL response: #{body.to_s[0, 500]}")
|
|
74
77
|
end
|
|
75
78
|
|
|
76
79
|
parsed
|
data/lib/graph_weaver/version.rb
CHANGED
data/lib/graph_weaver.rb
CHANGED
|
@@ -293,6 +293,15 @@ module GraphWeaver
|
|
|
293
293
|
# (`class Module; include T::Sig`) — extracted so it's stubbable in tests.
|
|
294
294
|
def global_tsig? = Module.include?(T::Sig)
|
|
295
295
|
|
|
296
|
+
# The closest entry in `dictionary` to `term` — a "did you mean" suggestion,
|
|
297
|
+
# or nil (also nil when did_you_mean isn't loadable). One home for the guard
|
|
298
|
+
# used by codegen validation, alias resolution, and the runtime prop hints.
|
|
299
|
+
def did_you_mean(dictionary, term)
|
|
300
|
+
return unless defined?(DidYouMean::SpellChecker)
|
|
301
|
+
|
|
302
|
+
DidYouMean::SpellChecker.new(dictionary: dictionary).correct(term).first
|
|
303
|
+
end
|
|
304
|
+
|
|
296
305
|
# Teach the generator how a GraphQL custom scalar deserializes into a
|
|
297
306
|
# rich Ruby object (and serializes back onto the wire when used as a
|
|
298
307
|
# variable):
|