graph_weaver 0.0.1
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 +7 -0
- data/CHANGELOG.md +6 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +176 -0
- data/LICENSE.txt +21 -0
- data/Makefile +13 -0
- data/NOTES.md +182 -0
- data/PLAN.md +74 -0
- data/README.md +87 -0
- data/graph_weaver.gemspec +24 -0
- data/lib/graph_weaver/codegen.rb +567 -0
- data/lib/graph_weaver/directive_defaults_patch.rb +32 -0
- data/lib/graph_weaver/http_executor.rb +31 -0
- data/lib/graph_weaver/schema_loader.rb +20 -0
- data/lib/graph_weaver/version.rb +3 -0
- data/lib/graph_weaver.rb +11 -0
- metadata +166 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "graphql"
|
|
5
|
+
require "sorbet-runtime"
|
|
6
|
+
|
|
7
|
+
# Generates plain, statically-typecheckable Ruby from a GraphQL query +
|
|
8
|
+
# schema: nested T::Structs, from_h casting code, and a sig'd execute
|
|
9
|
+
# method. Unlike StructTypes (which builds classes at parse time, visible
|
|
10
|
+
# only at runtime), the output is source on disk — srb tc sees the exact
|
|
11
|
+
# result type of each query.
|
|
12
|
+
#
|
|
13
|
+
# Supports queries and mutations; plain fields, inline fragments, named
|
|
14
|
+
# fragment spreads (including interface type conditions), union- and
|
|
15
|
+
# interface-typed fields (dispatch on __typename), enums (generated
|
|
16
|
+
# T::Enum), and typed variables (kwargs on execute). Input objects and
|
|
17
|
+
# subscriptions are still open.
|
|
18
|
+
class GraphWeaver::Codegen
|
|
19
|
+
# sorbet types for scalars
|
|
20
|
+
SCALARS = {
|
|
21
|
+
"ID" => "String",
|
|
22
|
+
"String" => "String",
|
|
23
|
+
"Int" => "Integer",
|
|
24
|
+
"Float" => "Float",
|
|
25
|
+
"Boolean" => "T::Boolean",
|
|
26
|
+
"Date" => "Date",
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# deserialization for scalars whose wire format differs from their Ruby
|
|
30
|
+
# type; anything absent passes through untouched
|
|
31
|
+
SCALAR_CASTS = {
|
|
32
|
+
"Date" => ->(expr) { "Date.iso8601(#{expr})" },
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# the inverse, for serializing variables onto the wire
|
|
36
|
+
SCALAR_SERIALIZERS = {
|
|
37
|
+
"Date" => ->(expr) { "#{expr}.iso8601" },
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
class Scalar
|
|
41
|
+
def initialize(name)
|
|
42
|
+
@name = name
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def bare_type
|
|
46
|
+
SCALARS.fetch(@name, "T.untyped")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def prop_type
|
|
50
|
+
"T.nilable(#{bare_type})"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def cast(expr, _depth)
|
|
54
|
+
SCALAR_CASTS.fetch(@name) { return expr }.call(expr)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def identity?
|
|
58
|
+
!SCALAR_CASTS.key?(@name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def serialize(expr, _depth)
|
|
62
|
+
SCALAR_SERIALIZERS.fetch(@name) { return expr }.call(expr)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def serialize_identity?
|
|
66
|
+
!SCALAR_SERIALIZERS.key?(@name)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def non_null? = false
|
|
70
|
+
def nested = nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class NonNull
|
|
74
|
+
def initialize(of)
|
|
75
|
+
@of = of
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def bare_type = @of.bare_type
|
|
79
|
+
def prop_type = bare_type
|
|
80
|
+
def cast(expr, depth) = @of.cast(expr, depth)
|
|
81
|
+
def identity? = @of.identity?
|
|
82
|
+
def serialize(expr, depth) = @of.serialize(expr, depth)
|
|
83
|
+
def serialize_identity? = @of.serialize_identity?
|
|
84
|
+
def non_null? = true
|
|
85
|
+
def nested = @of.nested
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class List
|
|
89
|
+
def initialize(of)
|
|
90
|
+
@of = of
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def bare_type
|
|
94
|
+
"T::Array[#{@of.prop_type}]"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def prop_type
|
|
98
|
+
"T.nilable(#{bare_type})"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def cast(expr, depth)
|
|
102
|
+
var = "v#{depth}"
|
|
103
|
+
element = if @of.non_null? || @of.identity?
|
|
104
|
+
@of.identity? ? var : @of.cast(var, depth + 1)
|
|
105
|
+
else
|
|
106
|
+
"#{var}&.then { |v#{depth + 1}| #{@of.cast("v#{depth + 1}", depth + 2)} }"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
"#{expr}.map { |#{var}| #{element} }"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def identity? = @of.identity?
|
|
113
|
+
|
|
114
|
+
def serialize(expr, depth)
|
|
115
|
+
var = "v#{depth}"
|
|
116
|
+
element = if @of.non_null? || @of.serialize_identity?
|
|
117
|
+
@of.serialize_identity? ? var : @of.serialize(var, depth + 1)
|
|
118
|
+
else
|
|
119
|
+
"#{var}&.then { |v#{depth + 1}| #{@of.serialize("v#{depth + 1}", depth + 2)} }"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
"#{expr}.map { |#{var}| #{element} }"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def serialize_identity? = @of.serialize_identity?
|
|
126
|
+
def non_null? = false
|
|
127
|
+
def nested = @of.nested
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
class ObjectNode
|
|
131
|
+
Field = Struct.new(:prop, :key, :node)
|
|
132
|
+
|
|
133
|
+
attr_reader :class_name, :fields
|
|
134
|
+
|
|
135
|
+
def initialize(class_name)
|
|
136
|
+
@class_name = class_name
|
|
137
|
+
@fields = []
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def bare_type = class_name
|
|
141
|
+
|
|
142
|
+
def prop_type
|
|
143
|
+
"T.nilable(#{bare_type})"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def cast(expr, _depth)
|
|
147
|
+
"#{class_name}.from_h(#{expr})"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def identity? = false
|
|
151
|
+
def non_null? = false
|
|
152
|
+
def nested = self
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
class EnumNode
|
|
156
|
+
attr_reader :class_name, :values
|
|
157
|
+
|
|
158
|
+
def initialize(class_name, values)
|
|
159
|
+
@class_name = class_name
|
|
160
|
+
@values = values
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def bare_type = class_name
|
|
164
|
+
|
|
165
|
+
def prop_type
|
|
166
|
+
"T.nilable(#{bare_type})"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def cast(expr, _depth)
|
|
170
|
+
"#{class_name}.deserialize(#{expr})"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def identity? = false
|
|
174
|
+
|
|
175
|
+
def serialize(expr, _depth)
|
|
176
|
+
"#{expr}.serialize"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def serialize_identity? = false
|
|
180
|
+
def non_null? = false
|
|
181
|
+
def nested = self
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
class UnionNode
|
|
185
|
+
attr_reader :class_name, :members # graphql type name => ObjectNode
|
|
186
|
+
|
|
187
|
+
def initialize(class_name, members)
|
|
188
|
+
@class_name = class_name
|
|
189
|
+
@members = members
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def bare_type = "#{class_name}::Type"
|
|
193
|
+
|
|
194
|
+
def prop_type
|
|
195
|
+
"T.nilable(#{bare_type})"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def cast(expr, _depth)
|
|
199
|
+
"#{class_name}.from_h(#{expr})"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def identity? = false
|
|
203
|
+
def non_null? = false
|
|
204
|
+
def nested = self
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# executor_const names anything responding to
|
|
208
|
+
# `execute(query, variables:)` whose result `to_h`s into
|
|
209
|
+
# {"data" => ..., "errors" => ...} — a Schema class for in-process
|
|
210
|
+
# execution, or an HttpExecutor for a remote endpoint. The generated
|
|
211
|
+
# execute also accepts an executor: override at runtime.
|
|
212
|
+
def initialize(schema:, executor_const:, query:, module_name:)
|
|
213
|
+
@schema = schema
|
|
214
|
+
@executor_const = executor_const
|
|
215
|
+
@query = query.strip
|
|
216
|
+
@module_name = module_name
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Development convenience: generate + eval in one step, no build
|
|
220
|
+
# artifact or checked-in file. Same runtime semantics as the generated
|
|
221
|
+
# file, but invisible to srb tc — use the build step for static typing.
|
|
222
|
+
def self.load(schema:, executor_const:, query:, module_name:)
|
|
223
|
+
source = new(schema:, executor_const:, query:, module_name:).generate
|
|
224
|
+
Object.class_eval(source, "(struct_codegen)", 1)
|
|
225
|
+
Object.const_get(module_name)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
VarDef = Struct.new(:kwarg, :wire, :node, :required)
|
|
229
|
+
|
|
230
|
+
def generate
|
|
231
|
+
errors = @schema.validate(@query)
|
|
232
|
+
if errors.any?
|
|
233
|
+
raise ArgumentError, "invalid query: #{errors.map(&:message).join("; ")}"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
doc = GraphQL.parse(@query)
|
|
237
|
+
@fragments = doc.definitions
|
|
238
|
+
.grep(GraphQL::Language::Nodes::FragmentDefinition)
|
|
239
|
+
.to_h { |fragment| [fragment.name, fragment] }
|
|
240
|
+
@variable_enums = {}
|
|
241
|
+
|
|
242
|
+
operation = doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition).first
|
|
243
|
+
root_type = case operation&.operation_type
|
|
244
|
+
when "query", nil then @schema.query
|
|
245
|
+
when "mutation" then @schema.mutation
|
|
246
|
+
else raise NotImplementedError, "unsupported operation: #{operation.operation_type}"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
variables = operation.variables.map do |var|
|
|
250
|
+
node = ast_type_ref(var.type)
|
|
251
|
+
# a variable is optional when nullable or defaulted; optional kwargs
|
|
252
|
+
# default to nil and are omitted from the wire
|
|
253
|
+
required = node.non_null? && var.default_value.nil?
|
|
254
|
+
VarDef.new(underscore(var.name), var.name, node, required)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
root = object_node(root_type, operation.selections, "Result")
|
|
258
|
+
|
|
259
|
+
out = []
|
|
260
|
+
out << "# typed: strict"
|
|
261
|
+
out << "# frozen_string_literal: true"
|
|
262
|
+
out << ""
|
|
263
|
+
out << "# Generated by GraphWeaver — do not edit."
|
|
264
|
+
out << ""
|
|
265
|
+
out << "module #{@module_name}"
|
|
266
|
+
out << " extend T::Sig"
|
|
267
|
+
out << ""
|
|
268
|
+
out << " QUERY = T.let(<<~'GRAPHQL', String)"
|
|
269
|
+
@query.each_line { |line| out << " #{line}".rstrip }
|
|
270
|
+
out << " GRAPHQL"
|
|
271
|
+
out << ""
|
|
272
|
+
@variable_enums.each_value do |enum|
|
|
273
|
+
emit_enum(enum, out, 1)
|
|
274
|
+
out << ""
|
|
275
|
+
end
|
|
276
|
+
emit_nested(root, out, 1)
|
|
277
|
+
out << ""
|
|
278
|
+
emit_execute(out, variables)
|
|
279
|
+
out << "end"
|
|
280
|
+
|
|
281
|
+
out.join("\n") + "\n"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
private
|
|
285
|
+
|
|
286
|
+
# GraphQL names are plain camelCase/SCREAMING_SNAKE — no acronym edge
|
|
287
|
+
# cases, so minimal inflection beats an activesupport dependency
|
|
288
|
+
def underscore(name)
|
|
289
|
+
name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def camelize(name)
|
|
293
|
+
name.split("_").map { |part| "#{part[0].upcase}#{part[1..]}" }.join
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Flatten a selection set as seen by `type`: plain fields collect
|
|
297
|
+
# directly; inline fragments and named spreads recurse when their type
|
|
298
|
+
# condition matches (exact name match — interface conditions are out of
|
|
299
|
+
# scope for this spike).
|
|
300
|
+
def gather(type, selections, out = {})
|
|
301
|
+
selections.each do |selection|
|
|
302
|
+
case selection
|
|
303
|
+
when GraphQL::Language::Nodes::Field
|
|
304
|
+
(out[selection.alias || selection.name] ||= []) << selection
|
|
305
|
+
when GraphQL::Language::Nodes::InlineFragment
|
|
306
|
+
gather(type, selection.selections, out) if applies?(selection.type&.name, type)
|
|
307
|
+
when GraphQL::Language::Nodes::FragmentSpread
|
|
308
|
+
fragment = @fragments.fetch(selection.name) do
|
|
309
|
+
raise ArgumentError, "unknown fragment: #{selection.name}"
|
|
310
|
+
end
|
|
311
|
+
gather(type, fragment.selections, out) if applies?(fragment.type.name, type)
|
|
312
|
+
else
|
|
313
|
+
raise NotImplementedError, "unsupported selection: #{selection.class}"
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
out
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# A fragment's type condition applies when it names this type exactly,
|
|
321
|
+
# or an interface/union this type belongs to (`... on Named { ... }`).
|
|
322
|
+
def applies?(condition, type)
|
|
323
|
+
return true if condition.nil? || condition == type.graphql_name
|
|
324
|
+
|
|
325
|
+
condition_type = @schema.get_type(condition)
|
|
326
|
+
return false unless condition_type
|
|
327
|
+
|
|
328
|
+
kind = condition_type.kind.name
|
|
329
|
+
(kind == "INTERFACE" || kind == "UNION") &&
|
|
330
|
+
@schema.possible_types(condition_type).include?(type)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def object_node(type, selections, class_name)
|
|
334
|
+
node = ObjectNode.new(class_name)
|
|
335
|
+
taken = [class_name]
|
|
336
|
+
|
|
337
|
+
gather(type, selections).each do |key, field_nodes|
|
|
338
|
+
field_name = field_nodes.first.name
|
|
339
|
+
prop = underscore(key)
|
|
340
|
+
|
|
341
|
+
child = if field_name == "__typename"
|
|
342
|
+
NonNull.new(Scalar.new("String"))
|
|
343
|
+
else
|
|
344
|
+
field_type = @schema.get_field(type.graphql_name, field_name).type
|
|
345
|
+
sub_selections = field_nodes.flat_map(&:selections)
|
|
346
|
+
|
|
347
|
+
case (core = unwrap(field_type)).kind.name
|
|
348
|
+
when "OBJECT"
|
|
349
|
+
name = pick_name(core.graphql_name, key, taken)
|
|
350
|
+
type_ref(field_type) { object_node(core, sub_selections, name) }
|
|
351
|
+
when "UNION", "INTERFACE"
|
|
352
|
+
name = pick_name(core.graphql_name, key, taken)
|
|
353
|
+
type_ref(field_type) { union_node(core, sub_selections, name) }
|
|
354
|
+
when "ENUM"
|
|
355
|
+
name = pick_name(core.graphql_name, key, taken)
|
|
356
|
+
# sorted so output is deterministic across schema sources
|
|
357
|
+
# (SDL round-trips reorder values alphabetically)
|
|
358
|
+
type_ref(field_type) { EnumNode.new(name, core.values.keys.sort) }
|
|
359
|
+
when "SCALAR"
|
|
360
|
+
type_ref(field_type) { Scalar.new(core.graphql_name) }
|
|
361
|
+
else
|
|
362
|
+
raise NotImplementedError, "unsupported kind: #{core.kind.name}"
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
node.fields << ObjectNode::Field.new(prop, key, child)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
node
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Abstract types (unions AND interfaces): one member struct per
|
|
373
|
+
# possible type; wire dispatch reads __typename, so the query must
|
|
374
|
+
# select it. For interfaces, the interface's own field selections
|
|
375
|
+
# gather into every member.
|
|
376
|
+
def union_node(type, selections, class_name)
|
|
377
|
+
unless gather(type, selections).key?("__typename")
|
|
378
|
+
raise ArgumentError, "select __typename on #{type.graphql_name} so #{class_name} can dispatch"
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# sorted so output is deterministic across schema sources
|
|
382
|
+
members = @schema.possible_types(type).sort_by(&:graphql_name).to_h do |possible|
|
|
383
|
+
[possible.graphql_name, object_node(possible, selections, possible.graphql_name)]
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
UnionNode.new(class_name, members)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Build a node from an AST type reference (variable definitions), where
|
|
390
|
+
# only the type NAME is known — resolve the core through the schema.
|
|
391
|
+
def ast_type_ref(ast_type)
|
|
392
|
+
case ast_type
|
|
393
|
+
when GraphQL::Language::Nodes::NonNullType
|
|
394
|
+
NonNull.new(ast_type_ref(ast_type.of_type))
|
|
395
|
+
when GraphQL::Language::Nodes::ListType
|
|
396
|
+
List.new(ast_type_ref(ast_type.of_type))
|
|
397
|
+
when GraphQL::Language::Nodes::TypeName
|
|
398
|
+
core = @schema.get_type(ast_type.name)
|
|
399
|
+
case core.kind.name
|
|
400
|
+
when "SCALAR"
|
|
401
|
+
Scalar.new(core.graphql_name)
|
|
402
|
+
when "ENUM"
|
|
403
|
+
@variable_enums[core.graphql_name] ||=
|
|
404
|
+
EnumNode.new(core.graphql_name, core.values.keys.sort)
|
|
405
|
+
else
|
|
406
|
+
raise NotImplementedError, "unsupported variable kind: #{core.kind.name}"
|
|
407
|
+
end
|
|
408
|
+
else
|
|
409
|
+
raise NotImplementedError, "unsupported type node: #{ast_type.class}"
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# rebuild the NON_NULL/LIST wrappers around the core node
|
|
414
|
+
def type_ref(type, &core)
|
|
415
|
+
case type.kind.name
|
|
416
|
+
when "NON_NULL"
|
|
417
|
+
NonNull.new(type_ref(type.of_type, &core))
|
|
418
|
+
when "LIST"
|
|
419
|
+
List.new(type_ref(type.of_type, &core))
|
|
420
|
+
else
|
|
421
|
+
core.call
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def unwrap(type)
|
|
426
|
+
type = type.of_type while type.kind.name == "NON_NULL" || type.kind.name == "LIST"
|
|
427
|
+
type
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def pick_name(type_name, key, taken)
|
|
431
|
+
candidate = type_name
|
|
432
|
+
candidate = "#{camelize(key)}#{type_name}" if taken.include?(candidate)
|
|
433
|
+
raise NotImplementedError, "class name collision: #{candidate}" if taken.include?(candidate)
|
|
434
|
+
|
|
435
|
+
taken << candidate
|
|
436
|
+
candidate
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def emit_nested(node, out, indent)
|
|
440
|
+
case node
|
|
441
|
+
when UnionNode then emit_union(node, out, indent)
|
|
442
|
+
when EnumNode then emit_enum(node, out, indent)
|
|
443
|
+
else emit_object(node, out, indent)
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def emit_enum(node, out, indent)
|
|
448
|
+
pad = " " * indent
|
|
449
|
+
|
|
450
|
+
out << "#{pad}class #{node.class_name} < T::Enum"
|
|
451
|
+
out << "#{pad} enums do"
|
|
452
|
+
node.values.each do |value|
|
|
453
|
+
out << "#{pad} #{camelize(value.downcase)} = new(#{value.inspect})"
|
|
454
|
+
end
|
|
455
|
+
out << "#{pad} end"
|
|
456
|
+
out << "#{pad}end"
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def emit_object(node, out, indent)
|
|
460
|
+
pad = " " * indent
|
|
461
|
+
|
|
462
|
+
out << "#{pad}class #{node.class_name} < T::Struct"
|
|
463
|
+
out << "#{pad} extend T::Sig"
|
|
464
|
+
out << ""
|
|
465
|
+
|
|
466
|
+
node.fields.filter_map { |field| field.node.nested }.each do |child|
|
|
467
|
+
emit_nested(child, out, indent + 1)
|
|
468
|
+
out << ""
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
node.fields.each do |field|
|
|
472
|
+
out << "#{pad} const :#{field.prop}, #{field.node.prop_type}"
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
out << ""
|
|
476
|
+
out << "#{pad} sig { params(data: T::Hash[String, T.untyped]).returns(#{node.class_name}) }"
|
|
477
|
+
out << "#{pad} def self.from_h(data)"
|
|
478
|
+
out << "#{pad} new("
|
|
479
|
+
node.fields.each do |field|
|
|
480
|
+
out << "#{pad} #{field.prop}: #{field_cast(field)},"
|
|
481
|
+
end
|
|
482
|
+
out << "#{pad} )"
|
|
483
|
+
out << "#{pad} end"
|
|
484
|
+
out << "#{pad}end"
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def emit_union(node, out, indent)
|
|
488
|
+
pad = " " * indent
|
|
489
|
+
|
|
490
|
+
out << "#{pad}module #{node.class_name}"
|
|
491
|
+
out << "#{pad} extend T::Sig"
|
|
492
|
+
out << ""
|
|
493
|
+
|
|
494
|
+
node.members.each_value do |member|
|
|
495
|
+
emit_object(member, out, indent + 1)
|
|
496
|
+
out << ""
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
member_names = node.members.values.map(&:class_name)
|
|
500
|
+
type_alias = member_names.size == 1 ? member_names.first : "T.any(#{member_names.join(", ")})"
|
|
501
|
+
out << "#{pad} Type = T.type_alias { #{type_alias} }"
|
|
502
|
+
out << ""
|
|
503
|
+
out << "#{pad} sig { params(data: T::Hash[String, T.untyped]).returns(Type) }"
|
|
504
|
+
out << "#{pad} def self.from_h(data)"
|
|
505
|
+
out << "#{pad} case (typename = data.fetch(\"__typename\"))"
|
|
506
|
+
node.members.each do |graphql_name, member|
|
|
507
|
+
out << "#{pad} when #{graphql_name.inspect} then #{member.class_name}.from_h(data)"
|
|
508
|
+
end
|
|
509
|
+
out << "#{pad} else raise \"unexpected __typename: \#{typename}\""
|
|
510
|
+
out << "#{pad} end"
|
|
511
|
+
out << "#{pad} end"
|
|
512
|
+
out << "#{pad}end"
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def emit_execute(out, variables)
|
|
516
|
+
sig_params = variables.map do |var|
|
|
517
|
+
kwarg_type = var.required ? var.node.prop_type : "T.nilable(#{var.node.bare_type})"
|
|
518
|
+
"#{var.kwarg}: #{kwarg_type}"
|
|
519
|
+
end
|
|
520
|
+
sig_params << "executor: T.untyped"
|
|
521
|
+
|
|
522
|
+
kwargs = variables.map { |var| var.required ? "#{var.kwarg}:" : "#{var.kwarg}: nil" }
|
|
523
|
+
kwargs << "executor: #{@executor_const}"
|
|
524
|
+
|
|
525
|
+
out << " sig { params(#{sig_params.join(", ")}).returns(Result) }"
|
|
526
|
+
out << " def self.execute(#{kwargs.join(", ")})"
|
|
527
|
+
|
|
528
|
+
required, optional = variables.partition(&:required)
|
|
529
|
+
if required.empty?
|
|
530
|
+
out << " variables = {}"
|
|
531
|
+
else
|
|
532
|
+
out << " variables = {"
|
|
533
|
+
required.each do |var|
|
|
534
|
+
out << " #{var.wire.inspect} => #{variable_serialize(var)},"
|
|
535
|
+
end
|
|
536
|
+
out << " }"
|
|
537
|
+
end
|
|
538
|
+
optional.each do |var|
|
|
539
|
+
out << " variables[#{var.wire.inspect}] = #{variable_serialize(var)} unless #{var.kwarg}.nil?"
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
out << ""
|
|
543
|
+
out << " result = executor.execute(QUERY, variables: variables).to_h"
|
|
544
|
+
out << " if (errors = result[\"errors\"])"
|
|
545
|
+
out << " raise \"query failed: \#{errors.inspect}\""
|
|
546
|
+
out << " end"
|
|
547
|
+
out << ""
|
|
548
|
+
out << " Result.from_h(result.fetch(\"data\"))"
|
|
549
|
+
out << " end"
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def variable_serialize(var)
|
|
553
|
+
var.node.serialize_identity? ? var.kwarg : var.node.serialize(var.kwarg, 1)
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def field_cast(field)
|
|
557
|
+
node = field.node
|
|
558
|
+
|
|
559
|
+
if node.non_null?
|
|
560
|
+
raw = "data.fetch(#{field.key.inspect})"
|
|
561
|
+
node.identity? ? raw : node.cast(raw, 1)
|
|
562
|
+
else
|
|
563
|
+
raw = "data[#{field.key.inspect}]"
|
|
564
|
+
node.identity? ? raw : "#{raw}&.then { |v1| #{node.cast("v1", 2)} }"
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# typed: false — monkeypatch; `self.class` resolves against the prepended host
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "graphql"
|
|
5
|
+
|
|
6
|
+
# graphql-ruby's SDL builder (BuildFromDefinition#prepare_directives)
|
|
7
|
+
# passes only the directive arguments present at the usage site, but
|
|
8
|
+
# Directive#initialize validates ALL defined arguments — so a defaulted
|
|
9
|
+
# non-null argument (`extension: Boolean! = false`) raises
|
|
10
|
+
# InvalidArgumentError when omitted, even though the SDL spec makes it
|
|
11
|
+
# optional. Real Apollo supergraph SDL (join v0.3) hits this on every
|
|
12
|
+
# @join__type usage.
|
|
13
|
+
#
|
|
14
|
+
# Fill in the declared defaults before validation. This is what upstream
|
|
15
|
+
# should do; present in graphql 2.6.3 (latest at time of writing).
|
|
16
|
+
#
|
|
17
|
+
# TODO: delete this file (and its requires) once
|
|
18
|
+
# https://github.com/rmosolgo/graphql-ruby/pull/5659 lands in a released
|
|
19
|
+
# graphql version and the Gemfile picks it up.
|
|
20
|
+
module DirectiveDefaultsPatch
|
|
21
|
+
def initialize(owner, **arguments)
|
|
22
|
+
self.class.all_argument_definitions.each do |arg_defn|
|
|
23
|
+
if !arguments.key?(arg_defn.keyword) && arg_defn.default_value?
|
|
24
|
+
arguments[arg_defn.keyword] = arg_defn.default_value
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
super(owner, **arguments)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
GraphQL::Schema::Directive.prepend(DirectiveDefaultsPatch)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "sorbet-runtime"
|
|
6
|
+
require "net/http"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
# Minimal HTTP transport satisfying the generated modules' executor
|
|
10
|
+
# interface: execute(query, variables:) => {"data" => ..., "errors" => ...}
|
|
11
|
+
class GraphWeaver::HttpExecutor
|
|
12
|
+
def initialize(url, headers: {})
|
|
13
|
+
@uri = URI(url)
|
|
14
|
+
@headers = headers
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def execute(query, variables: {})
|
|
18
|
+
request = Net::HTTP::Post.new(@uri, { "Content-Type" => "application/json" }.merge(@headers))
|
|
19
|
+
request.body = JSON.generate(query:, variables:)
|
|
20
|
+
|
|
21
|
+
response = Net::HTTP.start(@uri.hostname, @uri.port, use_ssl: @uri.scheme == "https") do |http|
|
|
22
|
+
http.request(request)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
26
|
+
raise "HTTP #{response.code}: #{response.body}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
JSON.parse(T.must(response.body))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "graphql"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
# Load a schema for codegen from either format a remote service can hand
|
|
8
|
+
# you: an introspection dump (.json) or SDL (.graphql/.gql).
|
|
9
|
+
module GraphWeaver::SchemaLoader
|
|
10
|
+
def self.load(path)
|
|
11
|
+
case File.extname(path)
|
|
12
|
+
when ".json"
|
|
13
|
+
GraphQL::Schema.from_introspection(JSON.parse(File.read(path)))
|
|
14
|
+
when ".graphql", ".gql"
|
|
15
|
+
GraphQL::Schema.from_definition(File.read(path))
|
|
16
|
+
else
|
|
17
|
+
raise ArgumentError, "unsupported schema format: #{path}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/graph_weaver.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
require "graphql"
|
|
2
|
+
require "sorbet-runtime"
|
|
3
|
+
|
|
4
|
+
require_relative "graph_weaver/codegen"
|
|
5
|
+
require_relative "graph_weaver/http_executor"
|
|
6
|
+
require_relative "graph_weaver/schema_loader"
|
|
7
|
+
require_relative "graph_weaver/version"
|
|
8
|
+
|
|
9
|
+
# opt-in: require "graph_weaver/directive_defaults_patch" to fix
|
|
10
|
+
# graphql-ruby dropping directive argument defaults when loading SDL
|
|
11
|
+
# (needed for Apollo supergraph SDL until rmosolgo/graphql-ruby#5659 ships)
|