graph_weaver 0.4.0 → 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 +82 -0
- data/CLAUDE.md +1 -2
- data/Gemfile.lock +3 -3
- data/docs/federation.md +36 -58
- data/docs/generated_modules.md +33 -0
- data/docs/scalars.md +70 -9
- data/graph_weaver.gemspec +3 -1
- data/lib/graph_weaver/client.rb +5 -4
- data/lib/graph_weaver/codegen/emit.rb +17 -1
- data/lib/graph_weaver/codegen/enum_type.rb +63 -5
- data/lib/graph_weaver/codegen/nodes.rb +7 -3
- data/lib/graph_weaver/codegen/scalar_type.rb +4 -1
- data/lib/graph_weaver/codegen.rb +175 -18
- 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 +162 -2
- 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 +17 -9
- metadata +3 -4
- data/lib/graph_weaver/directive_defaults_patch.rb +0 -32
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
|
|
@@ -465,20 +477,165 @@ class GraphWeaver::Codegen
|
|
|
465
477
|
node.fields << ObjectNode::Field.new(prop, key, child)
|
|
466
478
|
end
|
|
467
479
|
|
|
480
|
+
node.aliases = resolve_aliases(node)
|
|
468
481
|
node
|
|
469
482
|
end
|
|
470
483
|
|
|
471
|
-
#
|
|
472
|
-
#
|
|
473
|
-
|
|
474
|
-
|
|
484
|
+
# Resolve each registered alias (extend_type alias:) for this struct's type
|
|
485
|
+
# against its actual selection — path -> a typed delegator emitted into the
|
|
486
|
+
# struct body. Validated here, per query, so an unselected or untraversable
|
|
487
|
+
# path fails at generation with a pointed message.
|
|
488
|
+
def resolve_aliases(node)
|
|
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"
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Registered aliases for a GraphQL type: global registry plus this client's
|
|
512
|
+
# overlay (client-scoped wins on a name clash).
|
|
513
|
+
def type_aliases(graphql_name)
|
|
514
|
+
global = GraphWeaver::Codegen.type_registry[graphql_name]&.dig(:aliases) || {}
|
|
515
|
+
(global.merge(@types[graphql_name]&.dig(:aliases) || {}))
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
ALIAS_RESERVED = (%w[from_h serialize to_h].to_set + RUBY_KEYWORDS).freeze
|
|
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.
|
|
529
|
+
def resolve_alias(node, name, segments)
|
|
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
|
|
576
|
+
end
|
|
577
|
+
|
|
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
|
|
582
|
+
|
|
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("::")}::"
|
|
591
|
+
|
|
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})"
|
|
597
|
+
end
|
|
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
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
|
|
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|
|
|
475
627
|
case selection
|
|
476
628
|
when GraphQL::Language::Nodes::InlineFragment
|
|
477
|
-
selection.type&.name
|
|
629
|
+
[selection.type&.name, *concrete_conditions(core, selection.selections, visiting)]
|
|
478
630
|
when GraphQL::Language::Nodes::FragmentSpread
|
|
479
|
-
|
|
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
|
+
[]
|
|
480
637
|
end
|
|
481
|
-
end.uniq - [core.graphql_name]
|
|
638
|
+
end.compact.uniq - [core.graphql_name]
|
|
482
639
|
end
|
|
483
640
|
|
|
484
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
|
|
@@ -26,19 +26,179 @@ module GraphWeaver::SchemaLoader
|
|
|
26
26
|
raise ArgumentError, "unsupported schema content: #{source.lstrip[0, 80].inspect}"
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
build_sdl(source)
|
|
30
30
|
else # a file path
|
|
31
31
|
case File.extname(source)
|
|
32
32
|
when ".json"
|
|
33
33
|
GraphQL::Schema.from_introspection(JSON.parse(File.read(source)))
|
|
34
34
|
when ".graphql", ".gql"
|
|
35
|
-
|
|
35
|
+
build_sdl(File.read(source))
|
|
36
36
|
else
|
|
37
37
|
raise ArgumentError, "unsupported schema format: #{source}"
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Build a schema from SDL, first stripping Apollo Federation composition
|
|
43
|
+
# machinery when the SDL is a composed supergraph — so a supergraph dump
|
|
44
|
+
# (often the only artifact for the merged graph, and what the router
|
|
45
|
+
# actually serves) loads like any schema, with the federation plumbing
|
|
46
|
+
# gone rather than leaked into schema.types. Plain SDL passes through.
|
|
47
|
+
def self.build_sdl(sdl)
|
|
48
|
+
GraphQL::Schema.from_definition(federation_sdl?(sdl) ? strip_federation(sdl) : sdl)
|
|
49
|
+
end
|
|
50
|
+
private_class_method :build_sdl
|
|
51
|
+
|
|
52
|
+
# A composed Fed2 supergraph is marked by @join__* directives (every merged
|
|
53
|
+
# type carries them); a plain schema has none.
|
|
54
|
+
def self.federation_sdl?(sdl)
|
|
55
|
+
sdl.match?(/@join__\w/)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
FEDERATION_PREFIXES = %w[join__ link__ core__].freeze
|
|
59
|
+
FEDERATION_DIRECTIVES = %w[link core inaccessible].to_set.freeze
|
|
60
|
+
|
|
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)
|
|
70
|
+
!!name && (name.start_with?(*FEDERATION_PREFIXES) || FEDERATION_DIRECTIVES.include?(name))
|
|
71
|
+
end
|
|
72
|
+
private_class_method :federation_directive_name?
|
|
73
|
+
|
|
74
|
+
# Drop the composition machinery from supergraph SDL: the synthetic
|
|
75
|
+
# join__*/link__* type and directive definitions, and every @join__*/@link
|
|
76
|
+
# application on the types that remain. What's left is the merged graph's
|
|
77
|
+
# ordinary type shapes — exactly what codegen reads. Parsing is lenient (it's
|
|
78
|
+
# schema *building* that rejects the join directives), so we parse, filter the
|
|
79
|
+
# AST, and reprint clean SDL for from_definition — no graphql-ruby monkeypatch
|
|
80
|
+
# and no join__* leaking into schema.types.
|
|
81
|
+
def self.strip_federation(sdl)
|
|
82
|
+
doc = GraphQL.parse(sdl)
|
|
83
|
+
defs = remove_inaccessible(doc.definitions)
|
|
84
|
+
.reject { |defn| federation_definition?(defn) }
|
|
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
|
+
|
|
93
|
+
GraphQL::Language::Nodes::Document.new(definitions: defs).to_query_string
|
|
94
|
+
end
|
|
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
|
+
|
|
109
|
+
# Derive the API schema by dropping every element marked @inaccessible —
|
|
110
|
+
# present in the federated graph but hidden from the public API the router
|
|
111
|
+
# serves (its common use is safely rolling out a field on a shared type).
|
|
112
|
+
# Cascades: a field/argument/union-member/implements referencing a removed
|
|
113
|
+
# type goes too, and a type left with no fields/values/members is itself
|
|
114
|
+
# removed — repeated to a fixpoint. So codegen matches exactly what clients
|
|
115
|
+
# can query, without the over-permitting a raw supergraph would allow and
|
|
116
|
+
# without Apollo's JS tooling to subtract the API schema.
|
|
117
|
+
def self.remove_inaccessible(definitions)
|
|
118
|
+
removed = definitions.select { |d| type_definition?(d) && inaccessible?(d) }.map(&:name).to_set
|
|
119
|
+
loop do
|
|
120
|
+
survivors = definitions
|
|
121
|
+
.reject { |d| type_definition?(d) && removed.include?(d.name) }
|
|
122
|
+
.map { |d| prune_inaccessible(d, removed) }
|
|
123
|
+
# survivors already exclude `removed`, so anything newly emptied is fresh
|
|
124
|
+
newly = survivors.select { |d| type_definition?(d) && type_emptied?(d) }.map(&:name)
|
|
125
|
+
return survivors if newly.empty?
|
|
126
|
+
|
|
127
|
+
removed.merge(newly)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
private_class_method :remove_inaccessible
|
|
131
|
+
|
|
132
|
+
# A type-system type definition (object/interface/union/enum/input/scalar) —
|
|
133
|
+
# not a directive or schema definition.
|
|
134
|
+
def self.type_definition?(node)
|
|
135
|
+
node.respond_to?(:name) &&
|
|
136
|
+
!node.is_a?(GraphQL::Language::Nodes::DirectiveDefinition) &&
|
|
137
|
+
node.class.name.end_with?("TypeDefinition")
|
|
138
|
+
end
|
|
139
|
+
private_class_method :type_definition?
|
|
140
|
+
|
|
141
|
+
def self.inaccessible?(node)
|
|
142
|
+
node.respond_to?(:directives) && node.directives.any? { |d| d.name == "inaccessible" }
|
|
143
|
+
end
|
|
144
|
+
private_class_method :inaccessible?
|
|
145
|
+
|
|
146
|
+
# Whether pruning left the type with nothing the SDL grammar allows to be
|
|
147
|
+
# empty — a fieldless object/interface/input, a valueless enum, a memberless
|
|
148
|
+
# union — so it must be removed and its references cascaded.
|
|
149
|
+
def self.type_emptied?(node)
|
|
150
|
+
(node.respond_to?(:fields) && node.fields && node.fields.empty?) ||
|
|
151
|
+
(node.is_a?(GraphQL::Language::Nodes::EnumTypeDefinition) && node.values.empty?) ||
|
|
152
|
+
(node.is_a?(GraphQL::Language::Nodes::UnionTypeDefinition) && node.types.empty?)
|
|
153
|
+
end
|
|
154
|
+
private_class_method :type_emptied?
|
|
155
|
+
|
|
156
|
+
# the unwrapped (through NON_NULL/LIST) type name a field or argument references
|
|
157
|
+
def self.unwrapped_type_name(node)
|
|
158
|
+
type = node.type
|
|
159
|
+
type = type.of_type while type.respond_to?(:of_type)
|
|
160
|
+
type.name
|
|
161
|
+
end
|
|
162
|
+
private_class_method :unwrapped_type_name
|
|
163
|
+
|
|
164
|
+
# Remove @inaccessible children and children referencing a removed type,
|
|
165
|
+
# from a type's fields (and their arguments), enum values, union members,
|
|
166
|
+
# and implemented interfaces.
|
|
167
|
+
def self.prune_inaccessible(node, removed)
|
|
168
|
+
gone = lambda do |child|
|
|
169
|
+
inaccessible?(child) || (child.respond_to?(:type) && removed.include?(unwrapped_type_name(child)))
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
changes = {}
|
|
173
|
+
if node.respond_to?(:fields) && node.fields
|
|
174
|
+
changes[:fields] = node.fields.reject(&gone).map do |field|
|
|
175
|
+
args = field.respond_to?(:arguments) && field.arguments
|
|
176
|
+
args && args.any? ? field.merge(arguments: args.reject(&gone)) : field
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
changes[:values] = node.values.reject(&gone) if node.respond_to?(:values) && node.values
|
|
180
|
+
changes[:types] = node.types.reject { |t| removed.include?(t.name) } if node.respond_to?(:types) && node.types
|
|
181
|
+
if node.respond_to?(:interfaces) && node.interfaces
|
|
182
|
+
changes[:interfaces] = node.interfaces.reject { |i| removed.include?(i.name) }
|
|
183
|
+
end
|
|
184
|
+
changes.empty? ? node : node.merge(changes)
|
|
185
|
+
end
|
|
186
|
+
private_class_method :prune_inaccessible
|
|
187
|
+
|
|
188
|
+
# Recursively remove @join__*/@link applications from a definition and its
|
|
189
|
+
# fields, arguments, and enum values.
|
|
190
|
+
def self.strip_federation_directives(node)
|
|
191
|
+
changes = {}
|
|
192
|
+
if node.respond_to?(:directives) && node.directives
|
|
193
|
+
changes[:directives] = node.directives.reject { |d| federation_directive_name?(d.name) }
|
|
194
|
+
end
|
|
195
|
+
changes[:fields] = node.fields.map { |c| strip_federation_directives(c) } if node.respond_to?(:fields) && node.fields
|
|
196
|
+
changes[:arguments] = node.arguments.map { |c| strip_federation_directives(c) } if node.respond_to?(:arguments) && node.arguments
|
|
197
|
+
changes[:values] = node.values.map { |c| strip_federation_directives(c) } if node.respond_to?(:values) && node.values
|
|
198
|
+
changes.empty? ? node : node.merge(changes)
|
|
199
|
+
end
|
|
200
|
+
private_class_method :strip_federation_directives
|
|
201
|
+
|
|
42
202
|
# Run the standard introspection query through a transport and build a
|
|
43
203
|
# schema from the result:
|
|
44
204
|
#
|
|
@@ -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
|