graph_weaver 0.4.6 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1314 -0
- data/CLAUDE.md +100 -8
- data/DECISIONS.md +309 -0
- data/Gemfile.lock +23 -23
- data/NOTES.md +5 -5
- data/PLAN.md +106 -135
- data/README.md +115 -96
- data/REVIEW.md +946 -0
- data/docs/cassettes.md +75 -48
- data/docs/editors.md +82 -0
- data/docs/errors.md +32 -30
- data/docs/federation.md +520 -48
- data/docs/generated_modules.md +352 -137
- data/docs/getting_started.md +237 -67
- data/docs/logging.md +35 -6
- data/docs/real_world.md +21 -15
- data/docs/scalars.md +49 -154
- data/docs/testing.md +299 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +112 -0
- data/graph_weaver.gemspec +3 -1
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +114 -111
- data/lib/graph_weaver/codegen/aliases.rb +217 -0
- data/lib/graph_weaver/codegen/emit.rb +272 -258
- data/lib/graph_weaver/codegen/enum_type.rb +27 -124
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +593 -334
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +9 -1
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +14 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +67 -0
- data/lib/graph_weaver/query_module.rb +55 -0
- data/lib/graph_weaver/railtie.rb +23 -1
- data/lib/graph_weaver/representation.rb +74 -0
- data/lib/graph_weaver/response.rb +7 -0
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +794 -59
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +43 -8
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +160 -61
- data/lib/graph_weaver/testing/coverage.rb +165 -0
- data/lib/graph_weaver/testing/failure.rb +10 -23
- data/lib/graph_weaver/testing/fake_client.rb +181 -21
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1431 -0
- data/lib/graph_weaver/testing/subgraphs.rb +130 -0
- data/lib/graph_weaver/testing.rb +204 -14
- data/lib/graph_weaver/transport/faraday.rb +28 -10
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +67 -14
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +389 -170
- metadata +20 -3
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Registered aliases (extend_type alias:): dotted paths that project a nested
|
|
5
|
+
# selection onto the struct that owns it — `entity: "_entities.first"`,
|
|
6
|
+
# `tag: "meta.tag"` — as typed delegators. Resolved against the walked node
|
|
7
|
+
# tree, so a path the query doesn't select fails at generation.
|
|
8
|
+
#
|
|
9
|
+
# Mixed into Codegen — methods run with the generator instance state. The
|
|
10
|
+
# subsystem hangs off one seam: object_node's
|
|
11
|
+
# `node.aliases = resolve_aliases(node)`.
|
|
12
|
+
|
|
13
|
+
# the other half: extend_type, which populates the registry read here
|
|
14
|
+
require_relative "type_helpers"
|
|
15
|
+
|
|
16
|
+
class GraphWeaver::Codegen
|
|
17
|
+
module Aliases
|
|
18
|
+
include Kernel # for sorbet: hosts are Objects
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Resolve each registered alias (extend_type alias:) for this struct's type
|
|
23
|
+
# against its actual selection — path -> a typed delegator emitted into the
|
|
24
|
+
# struct body. Validated here, per query, so an unselected or untraversable
|
|
25
|
+
# path fails at generation with a pointed message.
|
|
26
|
+
def resolve_aliases(node)
|
|
27
|
+
type_aliases(node.graphql_type).filter_map do |name, spec|
|
|
28
|
+
# a bad accessor name (reserved, or colliding with a real field) is a
|
|
29
|
+
# registration mistake — it fails for every query, so it always raises,
|
|
30
|
+
# even for optional aliases (which otherwise mask it as "doesn't fit").
|
|
31
|
+
check_alias_name!(node, name)
|
|
32
|
+
begin
|
|
33
|
+
resolve_alias(node, name, spec[:segments])
|
|
34
|
+
rescue UnknownSegment => e
|
|
35
|
+
# names nothing in the schema, so no selection would fit — offering
|
|
36
|
+
# optional: as the way out would just hide the typo
|
|
37
|
+
raise e.class, qualify(node, e.message)
|
|
38
|
+
rescue GraphWeaver::Error => e
|
|
39
|
+
# a path that doesn't fit THIS query's selection: optional simply
|
|
40
|
+
# omits the accessor; strict breaks generation for every query on the
|
|
41
|
+
# type, so name the one that failed and the way out
|
|
42
|
+
next nil if spec[:optional]
|
|
43
|
+
|
|
44
|
+
raise e.class, "#{qualify(node, e.message)} " \
|
|
45
|
+
"— pass optional: true to skip selections that don't fit"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Name the failing query module — unless the message already names it,
|
|
51
|
+
# since a module and the type it queries can share a name (module Query
|
|
52
|
+
# on type Query would otherwise stutter).
|
|
53
|
+
def qualify(node, message)
|
|
54
|
+
return message if @module_name.nil? || @module_name == node.graphql_type
|
|
55
|
+
|
|
56
|
+
"#{@module_name}: #{message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def check_alias_name!(node, name)
|
|
60
|
+
taken = node.fields.any? { |f| f.prop == name } ||
|
|
61
|
+
ALIAS_RESERVED.include?(name) || RUBY_KEYWORDS.include?(name)
|
|
62
|
+
return unless taken
|
|
63
|
+
|
|
64
|
+
raise GraphWeaver::Error,
|
|
65
|
+
"alias #{name.inspect} on #{node.graphql_type} collides with an existing field or method"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Registered aliases for a GraphQL type (see extend_type alias:).
|
|
69
|
+
def type_aliases(graphql_name)
|
|
70
|
+
GraphWeaver::Codegen.type_registry[graphql_name]&.dig(:aliases) || {}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# methods every generated struct already answers to; Ruby keywords are
|
|
74
|
+
# checked alongside (RUBY_KEYWORDS is defined by the class this mixes into)
|
|
75
|
+
ALIAS_RESERVED = %w[from_h serialize to_h].to_set.freeze
|
|
76
|
+
# list selectors — pick one element out of a list-typed hop, always nilable
|
|
77
|
+
# (the list may be empty). Everything else is a field prop.
|
|
78
|
+
LIST_SELECTORS = %w[first last].freeze
|
|
79
|
+
# A segment naming no field of the GraphQL type at all — no selection could
|
|
80
|
+
# ever satisfy it, so it's a typo (or a wire-cased name), not a path that
|
|
81
|
+
# doesn't fit this query. optional: skips the latter, never this.
|
|
82
|
+
UnknownSegment = Class.new(GraphWeaver::Error)
|
|
83
|
+
|
|
84
|
+
# Walk a dotted path through this struct's selected shape, building the
|
|
85
|
+
# delegator expression (`meta&.tag`, `_entities.first&.name`) and its return
|
|
86
|
+
# type. A segment is a field prop, or `first`/`last` to pick a list element.
|
|
87
|
+
# Everything is checked against the node tree: a field on a non-object, a
|
|
88
|
+
# selector on a non-list, or an unselected segment raises. Any nilable hop
|
|
89
|
+
# (a nullable field, or a list element) makes the accessor nilable.
|
|
90
|
+
def resolve_alias(node, name, segments)
|
|
91
|
+
cur = T.let(node, T.untyped) # the node the path has reached
|
|
92
|
+
cur_nilable = T.let(false, T::Boolean) # is the expression so far nilable
|
|
93
|
+
nilable = T.let(false, T::Boolean) # is the accessor overall nilable
|
|
94
|
+
containers = T.let([], T::Array[String]) # nested-struct class names on the way to the leaf
|
|
95
|
+
expr = +""
|
|
96
|
+
|
|
97
|
+
segments.each do |seg|
|
|
98
|
+
# the first hop reads off the struct itself — spelled `self.` when the
|
|
99
|
+
# prop is a Ruby keyword (`self.next`), which bare would be the keyword
|
|
100
|
+
connector = if !expr.empty?
|
|
101
|
+
cur_nilable ? "&." : "."
|
|
102
|
+
else
|
|
103
|
+
RUBY_KEYWORDS.include?(seg) ? "self." : ""
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# `first`/`last` select an element only when the current hop is actually a
|
|
107
|
+
# list; otherwise they're an ordinary field (a schema field named `first`)
|
|
108
|
+
if LIST_SELECTORS.include?(seg) && list_of(cur)
|
|
109
|
+
expr << connector << seg
|
|
110
|
+
cur = list_of(cur).of
|
|
111
|
+
cur_nilable = true # first/last is nil on an empty list
|
|
112
|
+
nilable = true
|
|
113
|
+
else
|
|
114
|
+
obj = object_of(cur)
|
|
115
|
+
unless obj
|
|
116
|
+
hint = if list_of(cur)
|
|
117
|
+
" — use .first or .last to pick an element"
|
|
118
|
+
elsif LIST_SELECTORS.include?(seg)
|
|
119
|
+
" — .#{seg} needs a list"
|
|
120
|
+
else
|
|
121
|
+
""
|
|
122
|
+
end
|
|
123
|
+
raise GraphWeaver::Error,
|
|
124
|
+
"alias #{name.inspect} on #{node.graphql_type}: '#{seg}' can't be read here (not an object)#{hint}"
|
|
125
|
+
end
|
|
126
|
+
# the object a field is read from is the lexical container of its result
|
|
127
|
+
# (nested structs emit inside their parent); the aliased struct itself is
|
|
128
|
+
# the delegator's own scope, so it contributes no prefix
|
|
129
|
+
containers << obj.class_name unless obj.equal?(node)
|
|
130
|
+
field = obj.fields.find { |f| f.prop == seg }
|
|
131
|
+
unless field
|
|
132
|
+
check_segment_exists!(node, name, obj, seg)
|
|
133
|
+
props = obj.fields.map(&:prop)
|
|
134
|
+
suggestion = GraphWeaver.did_you_mean(props, seg)
|
|
135
|
+
hint = suggestion ? " — did you mean '#{suggestion}'?" : " (have: #{props.join(", ")})"
|
|
136
|
+
raise GraphWeaver::Error,
|
|
137
|
+
"alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a selected field#{hint}"
|
|
138
|
+
end
|
|
139
|
+
expr << connector << seg
|
|
140
|
+
cur = field.node
|
|
141
|
+
cur_nilable = !field.node.non_null?
|
|
142
|
+
nilable ||= cur_nilable
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
leaf = qualified_alias_type(cur, containers)
|
|
147
|
+
type = nilable && leaf != "T.untyped" ? "T.nilable(#{leaf})" : leaf
|
|
148
|
+
ObjectNode::Alias.new(name, expr, type)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Separate "this query didn't select it" from "no query could": a segment
|
|
152
|
+
# the schema doesn't declare on the type is a mistake in the registration,
|
|
153
|
+
# so it raises even for an optional alias — which otherwise turns a typo
|
|
154
|
+
# (or a wire-cased 'findPets') into an accessor that silently vanishes.
|
|
155
|
+
def check_segment_exists!(node, name, obj, seg)
|
|
156
|
+
type = obj.graphql_type && @schema.get_type(obj.graphql_type)
|
|
157
|
+
return unless type.respond_to?(:fields)
|
|
158
|
+
|
|
159
|
+
known = type.fields.keys.map { |field| GraphWeaver::Inflect.underscore(field) }
|
|
160
|
+
return if seg == "__typename" || known.include?(seg)
|
|
161
|
+
|
|
162
|
+
prop = GraphWeaver::Inflect.underscore(seg)
|
|
163
|
+
hint = if prop != seg && known.include?(prop)
|
|
164
|
+
# paths are the Ruby prop chain, not the GraphQL one — the classic miss
|
|
165
|
+
" — GraphQL fields generate snake_case props; use '#{prop}'"
|
|
166
|
+
elsif (suggestion = GraphWeaver.did_you_mean(known, prop))
|
|
167
|
+
" — did you mean '#{suggestion}'?"
|
|
168
|
+
else
|
|
169
|
+
" (has: #{known.sort.join(", ")})"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
raise UnknownSegment,
|
|
173
|
+
"alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a field of #{obj.graphql_type}#{hint}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The leaf's Sorbet type as referenced from the aliased struct. Generated
|
|
177
|
+
# nested constants (structs, enums, unions) must carry the container path,
|
|
178
|
+
# since the delegator's `sig` is emitted in an outer struct where a bare
|
|
179
|
+
# `Sub` wouldn't resolve; scalars, mapped enums, and hoisted union refs are
|
|
180
|
+
# already top-level. `containers` is the class-name chain to the leaf.
|
|
181
|
+
def qualified_alias_type(node, containers)
|
|
182
|
+
node = node.of if node.is_a?(NonNull)
|
|
183
|
+
prefix = containers.empty? ? "" : "#{containers.join("::")}::"
|
|
184
|
+
|
|
185
|
+
case node
|
|
186
|
+
when List
|
|
187
|
+
element = node.of.is_a?(NonNull) ? qualified_alias_type(node.of, containers) : begin
|
|
188
|
+
inner = qualified_alias_type(node.of, containers)
|
|
189
|
+
inner == "T.untyped" ? inner : "T.nilable(#{inner})"
|
|
190
|
+
end
|
|
191
|
+
"T::Array[#{element}]"
|
|
192
|
+
when ObjectNode, NarrowedNode then "#{prefix}#{node.class_name}"
|
|
193
|
+
# enums are emitted at module level (see Emit#module_level?), or aliased
|
|
194
|
+
# there from the shared enums module — either way, no container prefix
|
|
195
|
+
when EnumNode then node.class_name
|
|
196
|
+
when UnionNode then "#{prefix}#{node.bare_type}"
|
|
197
|
+
else node.bare_type # Scalar, MappedEnum, UnionRefNode — already top-level
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# the List a node wraps (through NON_NULL), or nil
|
|
202
|
+
def list_of(node)
|
|
203
|
+
node = T.let(node, T.untyped)
|
|
204
|
+
node = node.of while node.is_a?(NonNull)
|
|
205
|
+
node if node.is_a?(List)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# the ObjectNode a node resolves to for field access (through NON_NULL and a
|
|
209
|
+
# narrowed abstract member), or nil — unions/scalars/lists can't be read into
|
|
210
|
+
def object_of(node)
|
|
211
|
+
node = T.let(node, T.untyped)
|
|
212
|
+
node = node.of while node.is_a?(NonNull)
|
|
213
|
+
node = node.nested if node.is_a?(NarrowedNode)
|
|
214
|
+
node if node.is_a?(ObjectNode)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|