graph_weaver 0.0.1 → 0.2.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +279 -0
  4. data/Gemfile.lock +75 -17
  5. data/Makefile +11 -1
  6. data/NOTES.md +1 -1
  7. data/PLAN.md +85 -22
  8. data/README.md +86 -26
  9. data/docs/cassettes.md +76 -0
  10. data/docs/errors.md +134 -0
  11. data/docs/generated_modules.md +229 -0
  12. data/docs/getting_started.md +134 -0
  13. data/docs/logging.md +33 -0
  14. data/docs/real_world.md +53 -0
  15. data/docs/scalars.md +183 -0
  16. data/docs/testing.md +103 -0
  17. data/docs/transports.md +124 -0
  18. data/graph_weaver.gemspec +10 -1
  19. data/lib/graph_weaver/client.rb +200 -0
  20. data/lib/graph_weaver/codegen/emit.rb +286 -0
  21. data/lib/graph_weaver/codegen/enum_type.rb +149 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +356 -0
  23. data/lib/graph_weaver/codegen/scalar_type.rb +256 -0
  24. data/lib/graph_weaver/codegen.rb +396 -401
  25. data/lib/graph_weaver/errors.rb +322 -0
  26. data/lib/graph_weaver/hints.rb +63 -0
  27. data/lib/graph_weaver/inflect.rb +19 -0
  28. data/lib/graph_weaver/logging.rb +41 -0
  29. data/lib/graph_weaver/railtie.rb +29 -0
  30. data/lib/graph_weaver/response.rb +55 -0
  31. data/lib/graph_weaver/retry.rb +97 -0
  32. data/lib/graph_weaver/rspec.rb +59 -0
  33. data/lib/graph_weaver/schema_loader.rb +208 -8
  34. data/lib/graph_weaver/selection.rb +68 -0
  35. data/lib/graph_weaver/tasks.rb +71 -0
  36. data/lib/graph_weaver/testing/cassette.rb +224 -0
  37. data/lib/graph_weaver/testing/failure.rb +109 -0
  38. data/lib/graph_weaver/testing/fake_client.rb +228 -0
  39. data/lib/graph_weaver/testing/values.rb +98 -0
  40. data/lib/graph_weaver/testing.rb +106 -0
  41. data/lib/graph_weaver/transport/faraday.rb +56 -0
  42. data/lib/graph_weaver/transport/http.rb +87 -0
  43. data/lib/graph_weaver/transport.rb +120 -0
  44. data/lib/graph_weaver/version.rb +1 -1
  45. data/lib/graph_weaver.rb +268 -4
  46. metadata +132 -2
  47. data/lib/graph_weaver/http_executor.rb +0 -31
@@ -0,0 +1,286 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ # Source emission: turns the node tree into the generated module text.
5
+ # Mixed into Codegen — methods run with the generator instance state.
6
+ class GraphWeaver::Codegen
7
+ module Emit
8
+ include GraphWeaver::Inflect
9
+
10
+ private
11
+
12
+ def emit_nested(node, out, indent)
13
+ case node
14
+ when UnionNode then emit_union(node, out, indent)
15
+ when EnumNode then emit_enum(node, out, indent)
16
+ else emit_object(node, out, indent)
17
+ end
18
+ end
19
+
20
+ def emit_enum(node, out, indent)
21
+ pad = " " * indent
22
+
23
+ out << "#{pad}class #{node.class_name} < T::Enum"
24
+ out << "#{pad} enums do"
25
+ node.values.each do |value|
26
+ out << "#{pad} #{camelize(value.downcase)} = new(#{value.inspect})"
27
+ end
28
+ out << "#{pad} end"
29
+ out << "#{pad}end"
30
+ end
31
+
32
+ # module-level wire translation tables for an app-mapped enum
33
+ def emit_mapped_enum(node, out, indent)
34
+ pad = " " * indent
35
+ type = node.bare_type
36
+ prefix = node.const_prefix
37
+
38
+ out << "#{pad}# GraphQL enum #{node.graphql_name} <-> #{type} (registered mapping)"
39
+ out << "#{pad}#{prefix}_FROM_WIRE = T.let({"
40
+ node.mapping.each do |wire, member|
41
+ out << "#{pad} #{wire.inspect} => #{type}.deserialize(#{member.serialize.to_s.inspect}),"
42
+ end
43
+ out << "#{pad}}.freeze, T::Hash[String, #{type}])"
44
+ out << "#{pad}#{prefix}_TO_WIRE = T.let(#{prefix}_FROM_WIRE.invert.freeze, T::Hash[#{type}, String])"
45
+ end
46
+
47
+ def emit_object(node, out, indent)
48
+ pad = " " * indent
49
+
50
+ out << "#{pad}class #{node.class_name} < T::Struct"
51
+ out << "#{pad} extend T::Sig"
52
+ out << "#{pad} include GraphWeaver::Hints"
53
+ node.mixins.each do |mixin|
54
+ out << "#{pad} include #{mixin} # registered for #{node.graphql_type}"
55
+ end
56
+ out << ""
57
+
58
+ node.fields.filter_map { |field| field.node.nested }.each do |child|
59
+ emit_nested(child, out, indent + 1)
60
+ out << ""
61
+ end
62
+
63
+ node.fields.each do |field|
64
+ out << "#{pad} const :#{field.prop}, #{field.node.prop_type}"
65
+ end
66
+
67
+ out << ""
68
+ out << "#{pad} sig { params(data: T::Hash[String, T.untyped]).returns(#{node.class_name}) }"
69
+ out << "#{pad} def self.from_h(data)"
70
+ out << "#{pad} new("
71
+ node.fields.each do |field|
72
+ out << "#{pad} #{field.prop}: #{field_cast(field)},"
73
+ end
74
+ out << "#{pad} )"
75
+ out << "#{pad} rescue GraphWeaver::TypeError"
76
+ out << "#{pad} raise # already wrapped by a nested struct — keep the innermost context"
77
+ out << "#{pad} rescue TypeError, ArgumentError, KeyError => e"
78
+ out << "#{pad} raise GraphWeaver::TypeError.new(struct: self, error: e)"
79
+ out << "#{pad} end"
80
+ out << "#{pad}end"
81
+ end
82
+
83
+ def emit_union(node, out, indent)
84
+ pad = " " * indent
85
+
86
+ out << "#{pad}module #{node.class_name}"
87
+ out << "#{pad} extend T::Sig"
88
+ out << ""
89
+
90
+ node.members.each_value do |member|
91
+ emit_object(member, out, indent + 1)
92
+ out << ""
93
+ end
94
+
95
+ member_names = node.members.values.map(&:class_name)
96
+ type_alias = member_names.size == 1 ? member_names.first : "T.any(#{member_names.join(", ")})"
97
+ out << "#{pad} Type = T.type_alias { #{type_alias} }"
98
+ out << ""
99
+ out << "#{pad} sig { params(data: T::Hash[String, T.untyped]).returns(Type) }"
100
+ out << "#{pad} def self.from_h(data)"
101
+ out << "#{pad} case (typename = data.fetch(\"__typename\"))"
102
+ node.members.each do |graphql_name, member|
103
+ out << "#{pad} when #{graphql_name.inspect} then #{member.class_name}.from_h(data)"
104
+ end
105
+ out << "#{pad} else raise GraphWeaver::TypeError.new(struct: self, message: \"unexpected __typename: \#{typename}\")"
106
+ out << "#{pad} end"
107
+ out << "#{pad} end"
108
+ out << "#{pad}end"
109
+ end
110
+
111
+ def emit_execute(out, variables, flatten: nil)
112
+ out << " @client = T.let(nil, T.untyped)"
113
+ out << ""
114
+ out << " class << self"
115
+ out << " extend T::Sig"
116
+ out << ""
117
+ out << " sig { params(client: T.untyped).void }"
118
+ out << " attr_writer :client"
119
+ out << ""
120
+ out << " # default client (a GraphWeaver::Client or any transport) for"
121
+ out << " # execute: per-module override, else the app default"
122
+ out << " sig { returns(T.untyped) }"
123
+ out << " def client"
124
+ out << " @client || #{@client_const || "GraphWeaver.client!"}"
125
+ out << " end"
126
+ out << " end"
127
+ out << ""
128
+
129
+ # the kwarg surface: the input's fields when flattened, else one
130
+ # kwarg per declared variable — typed identically either way. The
131
+ # per-call client override rides as an optional POSITIONAL arg, so
132
+ # variables keep the entire kwarg namespace (nothing is reserved).
133
+ params = flatten ? flatten.fields.partition(&:required).flatten : variables
134
+
135
+ sig_params = ["client: T.untyped"]
136
+ sig_params += params.map do |param|
137
+ bare = param.node.coerce? ? param.node.coerce_input_type : param.node.bare_type
138
+ kwarg_type = param.required || bare == "T.untyped" ? bare : "T.nilable(#{bare})"
139
+ "#{kwarg_name(param)}: #{kwarg_type}"
140
+ end
141
+
142
+ kwargs = ["client = nil"]
143
+ kwargs += params.map { |param| param.required ? "#{kwarg_name(param)}:" : "#{kwarg_name(param)}: nil" }
144
+
145
+ # execute returns the full envelope; execute! is the strict shortcut for
146
+ # `execute(...).data!` — the typed result, or a raised QueryError.
147
+ forward = (["client"] + params.map { |param| "#{kwarg_name(param)}: #{kwarg_name(param)}" }).join(", ")
148
+
149
+ if flatten
150
+ out << " # $#{variables.first.wire}'s fields, flattened into kwargs (single input-object variable)"
151
+ end
152
+ out << " sig { params(#{sig_params.join(", ")}).returns(GraphWeaver::Response[Result]) }"
153
+ out << " def self.execute(#{kwargs.join(", ")})"
154
+
155
+ if flatten
156
+ fields = flatten.fields.map { |field| "#{field.prop}:" }.join(", ")
157
+ out << " variables = {"
158
+ out << " #{variables.first.wire.inspect} => #{flatten.class_name}.coerce({ #{fields} }).serialize,"
159
+ out << " }"
160
+ else
161
+ required, optional = variables.partition(&:required)
162
+ if required.empty?
163
+ out << " variables = {}"
164
+ else
165
+ out << " variables = {"
166
+ required.each do |var|
167
+ out << " #{var.wire.inspect} => #{variable_serialize(var)},"
168
+ end
169
+ out << " }"
170
+ end
171
+ optional.each do |var|
172
+ out << " variables[#{var.wire.inspect}] = #{variable_serialize(var)} unless #{var.kwarg}.nil?"
173
+ end
174
+ end
175
+
176
+ out << ""
177
+ out << " transport = GraphWeaver.resolve_transport(client || self.client)"
178
+ out << " raw = transport.execute(QUERY, variables: variables).to_h"
179
+ out << " GraphWeaver::Response[Result].new("
180
+ out << " data: (Result.from_h(raw[\"data\"]) if raw[\"data\"]),"
181
+ out << " errors: (raw[\"errors\"] || []).map { |e| GraphWeaver::GraphQLError.from_h(e) },"
182
+ out << " extensions: raw[\"extensions\"] || {},"
183
+ out << " )"
184
+ out << " end"
185
+ out << ""
186
+ out << " sig { params(#{sig_params.join(", ")}).returns(Result) }"
187
+ out << " def self.execute!(#{kwargs.join(", ")})"
188
+ out << " execute(#{forward}).data!"
189
+ out << " end"
190
+ end
191
+
192
+ # a kwarg surface entry is a VarDef (.kwarg) or, when flattened, an
193
+ # InputNode::Field (.prop)
194
+ def kwarg_name(param)
195
+ param.respond_to?(:kwarg) ? param.kwarg : param.prop
196
+ end
197
+
198
+ def variable_serialize(var)
199
+ value = var.node.coerce? ? var.node.coerce(var.kwarg) : var.kwarg
200
+ var.node.serialize_identity? ? value : var.node.serialize(value, 1)
201
+ end
202
+
203
+ def field_cast(field)
204
+ node = field.node
205
+
206
+ if node.non_null?
207
+ raw = "data.fetch(#{field.key.inspect})"
208
+ node.identity? ? raw : node.cast(raw, 1)
209
+ else
210
+ raw = "data[#{field.key.inspect}]"
211
+ node.identity? ? raw : "#{raw}&.then { |v1| #{node.cast("v1", 2)} }"
212
+ end
213
+ end
214
+
215
+ # a module-level T::Struct per input type; serialize builds the wire
216
+ # hash, omitting optional fields left nil
217
+ def emit_input(node, out, indent)
218
+ pad = " " * indent
219
+
220
+ out << "#{pad}class #{node.class_name} < T::Struct"
221
+ out << "#{pad} extend T::Sig"
222
+ out << ""
223
+ node.fields.each do |field|
224
+ default = field.required ? "" : ", default: nil"
225
+ out << "#{pad} const :#{field.prop}, #{field.node.prop_type}#{default}"
226
+ end
227
+ out << ""
228
+ # locals wear the reserved __gw prefix: prop readers are bare method
229
+ # calls here, and a prop named "result" or "value" would otherwise
230
+ # be shadowed — GraphQL reserves __-names, so no field can collide
231
+ out << "#{pad} sig { returns(T::Hash[String, T.untyped]) }"
232
+ out << "#{pad} def serialize"
233
+ out << "#{pad} __gw_result = T.let({}, T::Hash[String, T.untyped])"
234
+ node.fields.each do |field|
235
+ if field.required || field.node.serialize_identity?
236
+ value = field.node.serialize_identity? ? field.prop.to_s : field.node.serialize(field.prop.to_s, 1)
237
+ line = "__gw_result[#{field.wire.inspect}] = #{value}"
238
+ line += " unless #{field.prop}.nil?" unless field.required
239
+ out << "#{pad} #{line}"
240
+ else
241
+ # bind a local so sorbet's flow-sensitivity narrows the nilable
242
+ out << "#{pad} unless (__gw_value = #{field.prop}).nil?"
243
+ out << "#{pad} __gw_result[#{field.wire.inspect}] = #{field.node.serialize("__gw_value", 1)}"
244
+ out << "#{pad} end"
245
+ end
246
+ end
247
+ out << "#{pad} __gw_result"
248
+ out << "#{pad} end"
249
+ out << ""
250
+ out << "#{pad} # serialize, under the conventional name"
251
+ out << "#{pad} sig { returns(T::Hash[String, T.untyped]) }"
252
+ out << "#{pad} def to_h = serialize"
253
+ out << ""
254
+ out << "#{pad} # Build from a plain hash (underscored keys, Symbol or String):"
255
+ out << "#{pad} # enums accept their wire values, nested inputs accept hashes;"
256
+ out << "#{pad} # the struct's types are enforced on construction."
257
+ out << "#{pad} sig { params(value: T.any(#{node.class_name}, T::Hash[T.untyped, T.untyped])).returns(#{node.class_name}) }"
258
+ out << "#{pad} def self.coerce(value)"
259
+ out << "#{pad} return value if value.is_a?(#{node.class_name})"
260
+ out << ""
261
+ out << "#{pad} # a typo'd key must not silently drop off the wire"
262
+ out << "#{pad} GraphWeaver::Hints.validate_keys!(self, value)"
263
+ out << ""
264
+ out << "#{pad} new("
265
+ node.fields.each do |field|
266
+ raw = "value_at(value, :#{field.prop})"
267
+ expr = if field.node.hash_coerce_identity?
268
+ raw
269
+ elsif field.required
270
+ "#{raw}.then { |v1| #{field.node.hash_coerce("v1", 2)} }"
271
+ else
272
+ "#{raw}&.then { |v1| #{field.node.hash_coerce("v1", 2)} }"
273
+ end
274
+ out << "#{pad} #{field.prop}: #{expr},"
275
+ end
276
+ out << "#{pad} )"
277
+ out << "#{pad} end"
278
+ out << ""
279
+ out << "#{pad} sig { params(hash: T::Hash[T.untyped, T.untyped], key: Symbol).returns(T.untyped) }"
280
+ out << "#{pad} private_class_method def self.value_at(hash, key)"
281
+ out << "#{pad} hash.key?(key) ? hash[key] : hash[key.to_s]"
282
+ out << "#{pad} end"
283
+ out << "#{pad}end"
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,149 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ class GraphWeaver::Codegen
5
+ # How one GraphQL enum maps onto an app-owned T::Enum, so generated
6
+ # code speaks YOUR enum instead of generating one per module:
7
+ #
8
+ # class PetKind < T::Enum
9
+ # enums { Cat = new("cat"); Dog = new("dog") }
10
+ # end
11
+ #
12
+ # GraphWeaver.register_enum("Species", PetKind)
13
+ #
14
+ # The wire mapping is inferred by name ("CAT" <-> PetKind::Cat,
15
+ # case/underscore-insensitive against each member's serialized value);
16
+ # map: pins renames explicitly and merges over inference. Every wire
17
+ # value the schema declares must resolve — generation fails naming the
18
+ # gaps — unless fallback: names a member to absorb unknown values
19
+ # (forward-compat for servers that add members; inputs stay strict).
20
+ class EnumType
21
+ attr_reader :graphql_name, :type, :fallback, :requires
22
+
23
+ def initialize(graphql_name, type, map: nil, fallback: nil, requires: nil)
24
+ @graphql_name = graphql_name.to_s
25
+ unless type.is_a?(Class) && type < T::Enum
26
+ raise ArgumentError, "type: must be a T::Enum subclass, got #{type.inspect}"
27
+ end
28
+ unless type.name
29
+ raise ArgumentError, "type: must be a named constant (anonymous classes can't appear in generated source)"
30
+ end
31
+
32
+ @type = type
33
+ @map = map || {}
34
+ @fallback = fallback
35
+ @requires = Array(requires)
36
+
37
+ if fallback && !type.values.include?(fallback)
38
+ raise ArgumentError, "fallback: must be a #{type} member, got #{fallback.inspect}"
39
+ end
40
+ end
41
+
42
+ # wire value => member for every value the schema declares; raises
43
+ # naming the unmappable ones (unless fallback: absorbs them)
44
+ def mapping_for(wire_values)
45
+ mapping = {}
46
+ missing = []
47
+
48
+ wire_values.each do |wire|
49
+ member = @map[wire] || infer(wire)
50
+ member ? mapping[wire] = member : missing << wire
51
+ end
52
+
53
+ if missing.any? && !fallback
54
+ raise GraphWeaver::Error,
55
+ "#{type} has no member for #{graphql_name} value(s) #{missing.join(", ")} — " \
56
+ "add them, pin with map:, or absorb with fallback:"
57
+ end
58
+
59
+ mapping
60
+ end
61
+
62
+ private
63
+
64
+ # "CAT" matches serialize "cat"; "NOT_FOUND" matches "not_found"
65
+ def infer(wire)
66
+ @type.values.find { |member| normalize(member.serialize.to_s) == normalize(wire) }
67
+ end
68
+
69
+ def normalize(value)
70
+ value.downcase.delete("_")
71
+ end
72
+ end
73
+
74
+ class << self
75
+ # Map a GraphQL enum onto an app-owned T::Enum (see EnumType); the
76
+ # global default — client.register_enum scopes to one client.
77
+ def register_enum(graphql_name, type, map: nil, fallback: nil, requires: nil)
78
+ enum_registry[graphql_name.to_s] = EnumType.new(graphql_name, type, map:, fallback:, requires:)
79
+ end
80
+
81
+ # Bulk, inference-only form: register_enums("Species" => PetKind, ...)
82
+ def register_enums(mappings)
83
+ mappings.each { |graphql_name, type| register_enum(graphql_name, type) }
84
+ end
85
+
86
+ def enum_registry
87
+ @enum_registry ||= {}
88
+ end
89
+
90
+ # Attach app-owned helper modules to every struct generated from a
91
+ # GraphQL type — the logic stays in your code, generation wires it in:
92
+ #
93
+ # GraphWeaver.register_type("Pet", PetHelpers)
94
+ #
95
+ # Or build the mixin inline — the block is module_eval'd into a fresh
96
+ # module auto-named GraphWeaver::TypeHelpers::<Type>. Handy for quick
97
+ # decoration; srb tc can't see into block-defined methods, so prefer
98
+ # a named module where static checking matters:
99
+ #
100
+ # GraphWeaver.register_type("Pet") do
101
+ # def display_name = "#{name} the pet"
102
+ # end
103
+ #
104
+ # Additive: repeated registrations (and client-scoped ones) stack.
105
+ def register_type(graphql_name, *mixins, requires: nil, &block)
106
+ entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [] }
107
+ add_type_helpers(entry, graphql_name, mixins, requires, block)
108
+ end
109
+
110
+ def type_registry
111
+ @type_registry ||= {}
112
+ end
113
+
114
+ # shared with Client#register_type: build/validate the mixins and
115
+ # append them to a registry entry
116
+ def add_type_helpers(entry, graphql_name, mixins, requires, block)
117
+ mixins = mixins.dup
118
+ mixins << helper_module(graphql_name, block) if block
119
+
120
+ raise ArgumentError, "pass one or more helper modules, or a block" if mixins.empty?
121
+ mixins.each do |mixin|
122
+ unless mixin.is_a?(Module) && mixin.name
123
+ raise ArgumentError, "type helpers must be named modules, got #{mixin.inspect}"
124
+ end
125
+ end
126
+
127
+ entry[:mixins].concat(mixins)
128
+ entry[:requires].concat(Array(requires))
129
+ entry
130
+ end
131
+
132
+ # a block-built mixin needs a name generated files can reference:
133
+ # GraphWeaver::TypeHelpers::Pet (suffixed on re-registration)
134
+ def helper_module(graphql_name, block)
135
+ base = GraphWeaver::Inflect.camelize(graphql_name.to_s)
136
+ name = base
137
+ count = 1
138
+ name = "#{base}V#{count += 1}" while GraphWeaver::TypeHelpers.const_defined?(name, false)
139
+ GraphWeaver::TypeHelpers.const_set(name, Module.new(&block))
140
+ end
141
+ private :helper_module
142
+ end
143
+ end
144
+
145
+ module GraphWeaver
146
+ # Home of block-built type helpers (register_type with a block), which
147
+ # need constant names so generated files can reference them.
148
+ module TypeHelpers; end
149
+ end