graph_weaver 0.6.1 → 0.7.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 +4 -4
- data/Gemfile +8 -0
- data/Gemfile.lock +153 -4
- data/README.md +45 -79
- data/docs/alternatives.md +195 -0
- data/docs/cassettes.md +61 -50
- data/docs/editors.md +32 -47
- data/docs/errors.md +360 -103
- data/docs/federation.md +692 -473
- data/docs/generated_modules.md +441 -314
- data/docs/getting_started.md +370 -194
- data/docs/i18n.md +171 -0
- data/docs/logging.md +197 -50
- data/docs/real_world.md +42 -27
- data/docs/scalars.md +307 -176
- data/docs/testing.md +473 -220
- data/docs/transports.md +224 -151
- data/docs/upgrading.md +258 -305
- data/examples/README.md +38 -0
- data/examples/countries.rb +39 -0
- data/examples/federation.rb +62 -0
- data/examples/github/generate.rb +20 -0
- data/examples/github/generated/star_mutation.rb +126 -0
- data/examples/github/generated/stargazers_query.rb +232 -0
- data/examples/github/generated/starred_query.rb +151 -0
- data/examples/github/queries/star.graphql +8 -0
- data/examples/github/queries/stargazers.graphql +22 -0
- data/examples/github/queries/starred.graphql +11 -0
- data/examples/github/run.rb +43 -0
- data/examples/github/setup.rb +18 -0
- data/examples/rick_and_morty.rb +57 -0
- data/graph_weaver.gemspec +19 -3
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +69 -11
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +406 -195
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +284 -46
- data/lib/graph_weaver/federation.rb +129 -27
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +27 -15
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +80 -0
- data/lib/graph_weaver/internal/headers.rb +70 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +40 -4
- data/lib/graph_weaver/internal.rb +249 -14
- data/lib/graph_weaver/log_subscriber.rb +74 -0
- data/lib/graph_weaver/logging.rb +163 -19
- data/lib/graph_weaver/query_module.rb +44 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +45 -13
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +266 -56
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +34 -10
- data/lib/graph_weaver/testing/endpoint.rb +107 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +164 -45
- data/lib/graph_weaver/testing/router.rb +64 -13
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +48 -6
- data/lib/graph_weaver/transport.rb +134 -27
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +495 -106
- metadata +71 -3
- data/CHANGELOG.md +0 -2355
|
@@ -20,16 +20,20 @@ module GraphWeaver
|
|
|
20
20
|
# where {SchemaLoader.diff} needs the server and answers a different
|
|
21
21
|
# question (has the *server* drifted from my dump).
|
|
22
22
|
#
|
|
23
|
-
#
|
|
23
|
+
# Three kinds, because they mean different things:
|
|
24
24
|
#
|
|
25
25
|
# - **stale** — the supergraph carries `Product.weight` and no schema
|
|
26
26
|
# here defines it any more. Recompose.
|
|
27
|
+
# - **shape** — both carry `Product.weight`, with different types
|
|
28
|
+
# (`Int!` composed, `Float` here). Recompose — but nothing was added or
|
|
29
|
+
# dropped, so every coordinate matches and only the signatures tell.
|
|
27
30
|
# - **not composed in** — a schema here defines `Product.dimensions` and
|
|
28
31
|
# the supergraph doesn't carry it. Publish the subgraph.
|
|
29
32
|
#
|
|
30
33
|
# What "defines" means: a coordinate is compared only against the
|
|
31
34
|
# schemas that could *be* the subgraph the supergraph attributes it to —
|
|
32
|
-
# the ones defining every non-root type
|
|
35
|
+
# the ones defining every non-root type that subgraph declares **and**
|
|
36
|
+
# something the supergraph attributes to it alone. Exact field-set
|
|
33
37
|
# equality would be too strict in both directions: a subgraph carries
|
|
34
38
|
# federation plumbing (`_entities`, `_service`) the supergraph never
|
|
35
39
|
# has, and a field can legitimately sit in more than one subgraph
|
|
@@ -46,8 +50,7 @@ module GraphWeaver
|
|
|
46
50
|
# misleading on the graphs this is for.
|
|
47
51
|
class Drift
|
|
48
52
|
# Composition names the root types conventionally, and every subgraph
|
|
49
|
-
# declares one — so a root can't tell subgraphs apart
|
|
50
|
-
# is recognized by the other types it defines.
|
|
53
|
+
# declares one — so a root can't tell subgraphs apart (see #identifying).
|
|
51
54
|
ROOTS = %w[Query Mutation Subscription].freeze
|
|
52
55
|
private_constant :ROOTS
|
|
53
56
|
|
|
@@ -55,12 +58,20 @@ module GraphWeaver
|
|
|
55
58
|
# subgraphs resolve it, and no schema of theirs here defines it
|
|
56
59
|
attr_reader :stale
|
|
57
60
|
|
|
61
|
+
# { "Product.weight" => { "subgraphs" => ["products"], "supergraph" =>
|
|
62
|
+
# "Int!", "here" => ["Float"] } } — both carry the field, with
|
|
63
|
+
# different types. Its own kind because the fix is the same recompose
|
|
64
|
+
# and the cause is not: nothing was added or dropped, so the
|
|
65
|
+
# coordinates match and only the signatures say the composition is
|
|
66
|
+
# describing a graph nobody serves.
|
|
67
|
+
attr_reader :shape
|
|
68
|
+
|
|
58
69
|
# { "Product.dimensions" => ["Products::Schema"] } — defined here,
|
|
59
70
|
# absent from the supergraph
|
|
60
71
|
attr_reader :uncomposed
|
|
61
72
|
|
|
62
|
-
# { "inventory" => ["Warehouse"] } — subgraph => the
|
|
63
|
-
# identify it, which nothing here defines
|
|
73
|
+
# { "inventory" => ["Warehouse", "Warehouse.bays"] } — subgraph => the
|
|
74
|
+
# coordinates that would identify it, which nothing here defines
|
|
64
75
|
attr_reader :skipped
|
|
65
76
|
|
|
66
77
|
# subgraphs answered with fabricated data, so there's no real schema
|
|
@@ -70,6 +81,18 @@ module GraphWeaver
|
|
|
70
81
|
# every subgraph that was actually compared
|
|
71
82
|
attr_reader :checked
|
|
72
83
|
|
|
84
|
+
# every subgraph this supergraph names, compared or not
|
|
85
|
+
def subgraphs = @table.subgraphs
|
|
86
|
+
|
|
87
|
+
# The federated schemas in this process that no subgraph of this
|
|
88
|
+
# supergraph is. `stale` looks from the supergraph's side — every task
|
|
89
|
+
# here walks its subgraph list — and this looks from the code's, which
|
|
90
|
+
# is the only side a subgraph dropped from the composition is still on.
|
|
91
|
+
# Not `drift?`: a process that loads a subgraph of a supergraph nobody
|
|
92
|
+
# here reads looks exactly the same, and nothing on either side tells
|
|
93
|
+
# them apart.
|
|
94
|
+
def unplaced = @schemas.select { |schema| subgraph?(schema) } - @compared
|
|
95
|
+
|
|
73
96
|
# supergraph: the composed SDL (a path or the content); defaults to
|
|
74
97
|
# the conventional dump. subgraphs: the same map {Testing::Router}
|
|
75
98
|
# takes — a named schema skips detection, `:fake` (like anything else
|
|
@@ -85,15 +108,17 @@ module GraphWeaver
|
|
|
85
108
|
@given = @table.named_subgraphs(subgraphs)
|
|
86
109
|
@schemas = schemas || GraphWeaver::Internal::Schemas.loaded
|
|
87
110
|
@stale = {}
|
|
111
|
+
@shape = {}
|
|
88
112
|
@uncomposed = {}
|
|
89
113
|
@skipped = {}
|
|
90
114
|
@faked = []
|
|
91
115
|
@checked = []
|
|
116
|
+
@compared = []
|
|
92
117
|
compare
|
|
93
118
|
end
|
|
94
119
|
|
|
95
120
|
# whether the supergraph and the code here disagree — what CI gates on
|
|
96
|
-
def drift? = @stale.any? || @uncomposed.any?
|
|
121
|
+
def drift? = @stale.any? || @shape.any? || @uncomposed.any?
|
|
97
122
|
|
|
98
123
|
# Nothing was compared, so "no drift" is vacuous: the gate would pass
|
|
99
124
|
# whatever the subgraphs said. Categorically different from "checked 3
|
|
@@ -107,6 +132,7 @@ module GraphWeaver
|
|
|
107
132
|
def to_h
|
|
108
133
|
{
|
|
109
134
|
"stale" => @stale,
|
|
135
|
+
"shape" => @shape,
|
|
110
136
|
"uncomposed" => @uncomposed,
|
|
111
137
|
"skipped" => @skipped,
|
|
112
138
|
"faked" => @faked,
|
|
@@ -116,19 +142,29 @@ module GraphWeaver
|
|
|
116
142
|
def report
|
|
117
143
|
return "#{@source} names no subgraphs" if @table.subgraphs.empty?
|
|
118
144
|
|
|
119
|
-
|
|
145
|
+
# the scope caveat rides with the verdict that overclaims without it;
|
|
146
|
+
# a drift report is already telling you to recompose
|
|
147
|
+
[headline, *(NOT_COMPARED unless drift? || vacuous?),
|
|
148
|
+
*section(STALE, @stale), *shape_section, *section(UNCOMPOSED, @uncomposed),
|
|
120
149
|
*skipped_section, *faked_section].join("\n")
|
|
121
150
|
end
|
|
122
151
|
alias to_s report
|
|
123
152
|
|
|
124
153
|
def inspect
|
|
125
|
-
"#<#{self.class.name} #{@stale.size} stale, #{@
|
|
126
|
-
"#{@checked.size}/#{@table.subgraphs.size} checked>"
|
|
154
|
+
"#<#{self.class.name} #{@stale.size} stale, #{@shape.size} shape, " \
|
|
155
|
+
"#{@uncomposed.size} uncomposed, #{@checked.size}/#{@table.subgraphs.size} checked>"
|
|
127
156
|
end
|
|
128
157
|
|
|
129
158
|
private
|
|
130
159
|
|
|
160
|
+
# A pass says the supergraph still describes the fields these schemas
|
|
161
|
+
# have — not that the next composition would succeed. Both categories
|
|
162
|
+
# below are composition's business, and this reads neither.
|
|
163
|
+
NOT_COMPARED = "not compared: @key (added, removed, or made unresolvable), and one field " \
|
|
164
|
+
"two subgraphs define without @shareable — recompose to catch those"
|
|
165
|
+
|
|
131
166
|
STALE = "stale — the supergraph carries these, no schema here defines them (recompose):"
|
|
167
|
+
SHAPE = "shape — both carry these, with different types (recompose):"
|
|
132
168
|
UNCOMPOSED = "not composed in — a schema here defines these, the supergraph doesn't carry them:"
|
|
133
169
|
|
|
134
170
|
def compare
|
|
@@ -136,15 +172,24 @@ module GraphWeaver
|
|
|
136
172
|
next unless (fitting = comparable(name))
|
|
137
173
|
|
|
138
174
|
@checked << name
|
|
175
|
+
@compared |= fitting
|
|
139
176
|
record_stale(name, fitting)
|
|
140
177
|
record_uncomposed(name, fitting)
|
|
141
178
|
end
|
|
142
179
|
end
|
|
143
180
|
|
|
181
|
+
# A federation subgraph serves Query._service — that is how a gateway
|
|
182
|
+
# reads one to compose it, so every implementation carries it and an
|
|
183
|
+
# app's own API schema doesn't.
|
|
184
|
+
def subgraph?(schema) = !!schema.query&.fields&.key?("_service")
|
|
185
|
+
|
|
144
186
|
# The schemas to compare this subgraph against, or nil when there are
|
|
145
|
-
# none — recording why. A named schema is taken as given; otherwise
|
|
146
|
-
#
|
|
147
|
-
#
|
|
187
|
+
# none — recording why. A named schema is taken as given; otherwise a
|
|
188
|
+
# schema could be this subgraph when it defines every non-root type the
|
|
189
|
+
# supergraph says it declares AND at least one coordinate the supergraph
|
|
190
|
+
# attributes to it alone: the types say it is the same shape, the
|
|
191
|
+
# exclusive coordinate says it is THIS subgraph and not the neighbour it
|
|
192
|
+
# shares an entity with.
|
|
148
193
|
def comparable(name)
|
|
149
194
|
if @given.key?(name)
|
|
150
195
|
schema = @given[name]
|
|
@@ -156,8 +201,12 @@ module GraphWeaver
|
|
|
156
201
|
return
|
|
157
202
|
end
|
|
158
203
|
|
|
159
|
-
|
|
160
|
-
|
|
204
|
+
shape = identifying_types(name)
|
|
205
|
+
anchors = identifying(name)
|
|
206
|
+
fitting = @schemas.select do |schema|
|
|
207
|
+
shape.all? { |type| schema.get_type(type) } &&
|
|
208
|
+
anchors.any? { |coordinate| GraphWeaver::Internal::Schemas.defines?(schema, coordinate) }
|
|
209
|
+
end
|
|
161
210
|
return fitting if fitting.any?
|
|
162
211
|
|
|
163
212
|
@skipped[name] = anchors
|
|
@@ -168,23 +217,52 @@ module GraphWeaver
|
|
|
168
217
|
@table.types.select { |type| @table.declared_in(type).include?(name) }
|
|
169
218
|
end
|
|
170
219
|
|
|
171
|
-
# The types
|
|
172
|
-
#
|
|
220
|
+
# The types this subgraph's schema has to have: the ones it declares,
|
|
221
|
+
# minus the roots every subgraph has.
|
|
173
222
|
def identifying_types(name) = declared_types(name) - ROOTS
|
|
174
223
|
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
224
|
+
# What tells this subgraph apart: the coordinates the supergraph
|
|
225
|
+
# attributes to it and to nobody else. The entity two subgraphs extend
|
|
226
|
+
# is declared by both, so it says nothing about which of them a schema
|
|
227
|
+
# is — taken as the whole of the evidence it matched an absent subgraph
|
|
228
|
+
# to its neighbour and called every field only the absent one resolves
|
|
229
|
+
# stale. A subgraph that shares everything it declares has no evidence
|
|
230
|
+
# at all, and is "not here" rather than guessed at.
|
|
231
|
+
def identifying(name)
|
|
232
|
+
types = declared_types(name).select { |type| @table.declared_in(type) == [name] } - ROOTS
|
|
233
|
+
types + @table.types.flat_map do |type|
|
|
234
|
+
@table.declared_fields(type).filter_map do |field|
|
|
235
|
+
"#{type}.#{field}" if @table.owners(type, field) == [name]
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Fields the supergraph says this subgraph resolves that its candidate
|
|
241
|
+
# schemas no longer define (stale), or define with another type
|
|
242
|
+
# (shape). Every declared field, not only the explicitly routed ones —
|
|
243
|
+
# a field with no @join__field lives wherever its type does, and
|
|
244
|
+
# dropping one is exactly the drift this looks for.
|
|
245
|
+
#
|
|
246
|
+
# A field can legitimately sit in more than one candidate (@shareable,
|
|
247
|
+
# an @external copy), so one agreeing schema settles it: the subgraph
|
|
248
|
+
# this supergraph describes is here somewhere.
|
|
179
249
|
def record_stale(name, fitting)
|
|
180
250
|
declared_types(name).each do |type_name|
|
|
181
251
|
@table.declared_fields(type_name).each do |field_name|
|
|
182
252
|
next unless @table.owners(type_name, field_name).include?(name)
|
|
183
253
|
|
|
184
254
|
coordinate = "#{type_name}.#{field_name}"
|
|
185
|
-
|
|
255
|
+
here = fitting.filter_map { |schema| GraphWeaver::Internal::Schemas.signature(schema, coordinate) }
|
|
256
|
+
if here.empty?
|
|
257
|
+
(@stale[coordinate] ||= []) << name
|
|
258
|
+
next
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
composed = @table.signature(type_name, field_name)
|
|
262
|
+
next if composed.nil? || here.include?(composed)
|
|
186
263
|
|
|
187
|
-
(@
|
|
264
|
+
entry = (@shape[coordinate] ||= { "subgraphs" => [], "supergraph" => composed, "here" => here.uniq })
|
|
265
|
+
entry["subgraphs"] << name
|
|
188
266
|
end
|
|
189
267
|
end
|
|
190
268
|
end
|
|
@@ -221,6 +299,7 @@ module GraphWeaver
|
|
|
221
299
|
def headline
|
|
222
300
|
counts = [
|
|
223
301
|
("#{@stale.size} stale" if @stale.any?),
|
|
302
|
+
("#{@shape.size} shape" if @shape.any?),
|
|
224
303
|
("#{@uncomposed.size} not composed in" if @uncomposed.any?),
|
|
225
304
|
].compact
|
|
226
305
|
# "matches the schemas here" over nothing compared is the one verdict
|
|
@@ -228,12 +307,24 @@ module GraphWeaver
|
|
|
228
307
|
verdict =
|
|
229
308
|
if counts.any? then counts.join(", ")
|
|
230
309
|
elsif vacuous? then "compared against nothing here"
|
|
231
|
-
else "matches the schemas here"
|
|
310
|
+
else "matches the schemas here, field for field and type for type"
|
|
232
311
|
end
|
|
233
312
|
"#{@source}: #{verdict} " \
|
|
234
313
|
"(checked #{@checked.size} of #{@table.subgraphs.size} subgraphs)"
|
|
235
314
|
end
|
|
236
315
|
|
|
316
|
+
# both signatures, because neither alone says which way to move: the
|
|
317
|
+
# supergraph's is what callers are generating against, the local one is
|
|
318
|
+
# what would actually answer
|
|
319
|
+
def shape_section
|
|
320
|
+
return [] if @shape.empty?
|
|
321
|
+
|
|
322
|
+
["", SHAPE, *@shape.sort.map do |coordinate, entry|
|
|
323
|
+
" #{coordinate} (#{entry["subgraphs"].join(", ")}): #{entry["supergraph"]} in the " \
|
|
324
|
+
"supergraph, #{entry["here"].join(", ")} here"
|
|
325
|
+
end]
|
|
326
|
+
end
|
|
327
|
+
|
|
237
328
|
def section(title, entries)
|
|
238
329
|
return [] if entries.empty?
|
|
239
330
|
|
|
@@ -245,9 +336,20 @@ module GraphWeaver
|
|
|
245
336
|
def skipped_section
|
|
246
337
|
return [] if @skipped.empty?
|
|
247
338
|
|
|
248
|
-
["", "not checked —
|
|
249
|
-
"(running elsewhere, or the
|
|
250
|
-
*@skipped.sort.map { |name,
|
|
339
|
+
["", "not checked — no schema here matches what the supergraph says only these resolve " \
|
|
340
|
+
"(running elsewhere, or the subgraph is gone):",
|
|
341
|
+
*@skipped.sort.map { |name, what| " #{name} (#{evidence(what)})" }]
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# how many coordinates the report names before it says "and N more"
|
|
345
|
+
SAMPLE = 5
|
|
346
|
+
private_constant :SAMPLE
|
|
347
|
+
|
|
348
|
+
def evidence(coordinates)
|
|
349
|
+
return "the supergraph attributes nothing to it alone" if coordinates.empty?
|
|
350
|
+
return coordinates.join(", ") if coordinates.size <= SAMPLE
|
|
351
|
+
|
|
352
|
+
"#{coordinates.first(SAMPLE).join(", ")} and #{coordinates.size - SAMPLE} more"
|
|
251
353
|
end
|
|
252
354
|
|
|
253
355
|
def faked_section
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# One schema and everything generation needs to know about it: where its
|
|
5
|
+
# queries live, where its Ruby goes, which client its modules call, what its
|
|
6
|
+
# custom scalars and enums mean, and the namespace that keeps its constants
|
|
7
|
+
# off another graph's.
|
|
8
|
+
#
|
|
9
|
+
# An app has at least one. The top-level settings (GraphWeaver.schema_path,
|
|
10
|
+
# queries_paths, generated_paths, types_module) describe it, so a single-schema
|
|
11
|
+
# app never says the word "graph" — and a second schema is a second graph
|
|
12
|
+
# rather than a second copy of the recipe.
|
|
13
|
+
|
|
14
|
+
module GraphWeaver
|
|
15
|
+
class Graph
|
|
16
|
+
# nil for the default graph — the one the settings describe, which has
|
|
17
|
+
# nothing to be called because there is nothing to tell it apart from.
|
|
18
|
+
attr_reader :name
|
|
19
|
+
|
|
20
|
+
# Each of these falls back to the matching top-level setting, so a graph
|
|
21
|
+
# says only what differs. output is one directory (a graph writes to one
|
|
22
|
+
# place); generated_paths stays the list of places to READ from.
|
|
23
|
+
def initialize(name: nil, schema: nil, queries: nil, output: nil, client: nil,
|
|
24
|
+
namespace: nil, types_module: nil, registrations: nil)
|
|
25
|
+
@name = name
|
|
26
|
+
@schema = schema
|
|
27
|
+
@queries = queries
|
|
28
|
+
@output = output
|
|
29
|
+
@client = client
|
|
30
|
+
@namespace = namespace
|
|
31
|
+
@types_module = types_module
|
|
32
|
+
@registrations = registrations
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The registrations this graph generates with: the top-level ones as they
|
|
36
|
+
# stand now, with this graph's own laid on top. Read here rather than
|
|
37
|
+
# captured at declaration, because in Rails the initializer that registers
|
|
38
|
+
# a scalar and the one that declares a graph run in alphabetical filename
|
|
39
|
+
# order — which can't be allowed to decide whether the registration lands.
|
|
40
|
+
# The default graph is the top-level registry.
|
|
41
|
+
def registry
|
|
42
|
+
return GraphWeaver::Codegen.registry unless @registrations
|
|
43
|
+
|
|
44
|
+
GraphWeaver::Codegen.registry.dup.tap do |registry|
|
|
45
|
+
registry.graph_name = @name
|
|
46
|
+
@registrations.each { |registration| replay(registry, registration) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# One recorded registration, against this read's copy of the registry.
|
|
51
|
+
#
|
|
52
|
+
# A block-form extend_type mints a module whose constant name generated
|
|
53
|
+
# source spells, and a registration is replayed on every read — so the
|
|
54
|
+
# block runs once, at the declaration (GraphBuilder.build reads the
|
|
55
|
+
# registry there), and every read after replays the module it made.
|
|
56
|
+
def replay(registry, registration)
|
|
57
|
+
call, args, kwargs, block = registration
|
|
58
|
+
entry = registry.public_send(call, *args, **kwargs, &block)
|
|
59
|
+
return unless block && call == :extend_type
|
|
60
|
+
|
|
61
|
+
registration[1] = args + [entry[:mixins].last]
|
|
62
|
+
registration[3] = nil
|
|
63
|
+
end
|
|
64
|
+
private :replay
|
|
65
|
+
|
|
66
|
+
def queries = @queries || GraphWeaver.queries_paths
|
|
67
|
+
def output = @output || GraphWeaver.generated_paths.first
|
|
68
|
+
def client = @client
|
|
69
|
+
|
|
70
|
+
# Every constant this graph generates lives under `namespace:` — the query
|
|
71
|
+
# modules and the shared types module alike. Two schemas that each have a
|
|
72
|
+
# person.graphql, or that each hoist an enum, otherwise fight over one
|
|
73
|
+
# constant; this is the one knob that settles both.
|
|
74
|
+
def namespace = @namespace
|
|
75
|
+
|
|
76
|
+
def types_module
|
|
77
|
+
@types_module || (namespace ? "#{namespace}::#{GraphWeaver.types_module}" : GraphWeaver.types_module)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The graphql-ruby schema, however it was named: a class, a Client, a path
|
|
81
|
+
# to a dump, SDL, or a callable returning one. Resolved each time rather
|
|
82
|
+
# than memoized — in dev the class object is replaced on reload, as
|
|
83
|
+
# Internal::Util.live_schema notes, and a callable is how an initializer
|
|
84
|
+
# names a class Zeitwerk hasn't loaded yet.
|
|
85
|
+
def schema
|
|
86
|
+
return GraphWeaver::Internal::Util.locate_schema! unless @schema
|
|
87
|
+
|
|
88
|
+
GraphWeaver::Internal::Util.schema_for(named_source)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Whether this graph names its own schema. The default graph doesn't — it
|
|
92
|
+
# is whatever dump is at schema_path — which is what lets check_queries
|
|
93
|
+
# re-introspect that one and leave a named schema alone.
|
|
94
|
+
def named_schema? = !@schema.nil?
|
|
95
|
+
|
|
96
|
+
# The dump this graph's schema was named by, when it was named by a file —
|
|
97
|
+
# what a validation error's subgraph branding is read off. nil for a live
|
|
98
|
+
# class, a Client, or inline SDL.
|
|
99
|
+
def dump_path
|
|
100
|
+
path = named_dump_path
|
|
101
|
+
path if path && File.exist?(path)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The dump this graph's schema NAMES, file or no file — dump_path once
|
|
105
|
+
# there is one, and where `schema:refresh` writes the first. A path is
|
|
106
|
+
# told from SDL by its extension, which is how SchemaLoader reads one
|
|
107
|
+
# anyway, and is the only question askable before the file exists.
|
|
108
|
+
def named_dump_path
|
|
109
|
+
return GraphWeaver::SchemaLoader.locate_path unless @schema
|
|
110
|
+
|
|
111
|
+
source = named_source
|
|
112
|
+
path = source.respond_to?(:to_path) ? source.to_path : source
|
|
113
|
+
path if path.is_a?(String) && GraphWeaver::SchemaLoader.dump_path?(path)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# What this graph's dump is derived FROM — what `schema:refresh`
|
|
117
|
+
# rewrites it from and `schema:diff` compares it against. The url the
|
|
118
|
+
# dump recorded, else the graphql-ruby schema class this process runs.
|
|
119
|
+
# nil when the dump is its own source: a committed artifact with nothing
|
|
120
|
+
# behind it to re-read.
|
|
121
|
+
#
|
|
122
|
+
# The recorded url wins because it is a fact the file states about
|
|
123
|
+
# itself, where a live class is an inference from whatever this app
|
|
124
|
+
# happens to execute against — and refreshing a foreign API's dump from
|
|
125
|
+
# the app's own schema would overwrite it with the wrong graph.
|
|
126
|
+
def dump_source
|
|
127
|
+
path = dump_path
|
|
128
|
+
# A dump this graph names but hasn't written yet has no provenance to
|
|
129
|
+
# read a source off, so the source is where its modules already post.
|
|
130
|
+
# That is what bootstraps a second graph's dump: URL= names one
|
|
131
|
+
# endpoint, and each graph has its own.
|
|
132
|
+
url = path ? GraphWeaver::SchemaLoader.provenance(path)&.dig("url") : (client_url if named_dump_path)
|
|
133
|
+
url || live_schema
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The url this graph's modules post to, or nil. `client:` holds a constant
|
|
137
|
+
# or its name — codegen spells it into source — so a name is resolved here
|
|
138
|
+
# the way the generated DEFAULT_CLIENT lambda resolves it; a graph baking
|
|
139
|
+
# none posts to the app default, which is where its modules go too.
|
|
140
|
+
def client_url
|
|
141
|
+
client = @client.is_a?(String) ? resolve_client! : @client
|
|
142
|
+
client ||= GraphWeaver.client
|
|
143
|
+
target = (client.transport if client.respond_to?(:transport)) || client
|
|
144
|
+
target.url if target.respond_to?(:url)
|
|
145
|
+
end
|
|
146
|
+
private :client_url
|
|
147
|
+
|
|
148
|
+
def resolve_client!
|
|
149
|
+
Object.const_get(@client)
|
|
150
|
+
rescue NameError
|
|
151
|
+
raise GraphWeaver::Error, "graph #{name.inspect} bakes client #{@client.inspect} into its " \
|
|
152
|
+
"modules and nothing defines that constant, so there is no endpoint to introspect " \
|
|
153
|
+
"#{named_dump_path} from"
|
|
154
|
+
end
|
|
155
|
+
private :resolve_client!
|
|
156
|
+
|
|
157
|
+
# The composed supergraph this graph plans against, or nil — the dump it
|
|
158
|
+
# names (for the default graph, the conventional one) when that dump
|
|
159
|
+
# carries the @join__* routing table. A graph whose schema is an API
|
|
160
|
+
# schema, or a live class, is in no supergraph of its own.
|
|
161
|
+
#
|
|
162
|
+
# One rule, asked by everything that needs one: the federation rake tasks
|
|
163
|
+
# report per graph off this, and Testing::Config resolves :router's
|
|
164
|
+
# supergraph through it.
|
|
165
|
+
def supergraph
|
|
166
|
+
path = dump_path
|
|
167
|
+
path if path && GraphWeaver::Internal::Util.composed?(path)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The graphql-ruby schema class this graph runs in-process, or nil. The
|
|
171
|
+
# default graph's is the app client's (Internal::Util.live_schema).
|
|
172
|
+
def live_schema
|
|
173
|
+
return GraphWeaver::Internal::Util.live_schema unless @schema
|
|
174
|
+
|
|
175
|
+
source = named_source
|
|
176
|
+
source if source.is_a?(Class) && source <= GraphQL::Schema
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# What `schema:` was given, with a callable called. A Proc is how an
|
|
180
|
+
# initializer names an autoloaded class; calling it here rather than at
|
|
181
|
+
# declaration is the whole point of allowing one — and the commonest way
|
|
182
|
+
# for one to be wrong is to answer nil (a config value that wasn't set, a
|
|
183
|
+
# guarded `defined?`, a safe_constantize). Refuse it here, where the graph
|
|
184
|
+
# and the setting are still in hand: dump_path, supergraph and live_schema
|
|
185
|
+
# all read this too, and every one of them would otherwise answer nil and
|
|
186
|
+
# send the blame somewhere else.
|
|
187
|
+
def named_source
|
|
188
|
+
source = @schema.respond_to?(:call) ? @schema.call : @schema
|
|
189
|
+
return source unless source.nil?
|
|
190
|
+
|
|
191
|
+
raise GraphWeaver::Error, "schema#{described} resolved to nil — schema takes a graphql-ruby " \
|
|
192
|
+
"schema class, a Client, a path to a dump, SDL, or a callable returning one"
|
|
193
|
+
end
|
|
194
|
+
private :named_source
|
|
195
|
+
|
|
196
|
+
# The module `path` generates, and the file it lands in. The namespace is
|
|
197
|
+
# the only thing a graph adds to the naming rule; the rest is the file name,
|
|
198
|
+
# as it is everywhere else.
|
|
199
|
+
def generated_names(path, source)
|
|
200
|
+
module_name, filename = GraphWeaver::Internal::Util.generated_names(path, source)
|
|
201
|
+
[namespace ? "#{namespace}::#{module_name}" : module_name, filename]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# How a message names this graph: " in graph :billing", or nothing at all
|
|
205
|
+
# for the default one, so a single-schema app's errors are unchanged.
|
|
206
|
+
def described = name ? " in graph #{name.inspect}" : ""
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
module Internal
|
|
210
|
+
# What a `GraphWeaver.graph` block is evaluated against: it collects the
|
|
211
|
+
# settings, runs the registrations, and refuses everything else — a typo
|
|
212
|
+
# is a mistake worth a message, not a call that vanishes.
|
|
213
|
+
#
|
|
214
|
+
# A separate object rather than the Graph itself, so a block can't reach a
|
|
215
|
+
# Graph's internals and a Graph stays a plain value. An app writes the
|
|
216
|
+
# block and never names this.
|
|
217
|
+
class GraphBuilder
|
|
218
|
+
# Everything a graph can say, in the order the docs teach it. `schema "x"`
|
|
219
|
+
# sets and bare `schema` reads back — there is no `schema =` form, because
|
|
220
|
+
# instance_eval would make that a local variable that silently does nothing.
|
|
221
|
+
SETTINGS = %i[schema queries output client namespace types_module].freeze
|
|
222
|
+
# The same three calls an app already writes at the top level, scoped here
|
|
223
|
+
# to this graph alone.
|
|
224
|
+
REGISTRATIONS = %i[register_scalar register_enum extend_type].freeze
|
|
225
|
+
# These three end up spelled in generated source, so each takes the
|
|
226
|
+
# constant or its name and stores the name.
|
|
227
|
+
CONSTANT_SETTINGS = %i[client namespace types_module].freeze
|
|
228
|
+
# …and these two are spelled as a `module` DEFINITION rather than a
|
|
229
|
+
# reference, which is why a root anchor is refused on them below.
|
|
230
|
+
MODULE_SETTINGS = %i[namespace types_module].freeze
|
|
231
|
+
private_constant :CONSTANT_SETTINGS, :MODULE_SETTINGS
|
|
232
|
+
|
|
233
|
+
attr_reader :settings, :registrations
|
|
234
|
+
|
|
235
|
+
# The Graph a block describes.
|
|
236
|
+
def self.build(name, &block)
|
|
237
|
+
builder = new(name)
|
|
238
|
+
builder.instance_eval(&block)
|
|
239
|
+
graph = GraphWeaver::Graph.new(name:, registrations: builder.registrations, **builder.settings)
|
|
240
|
+
# the block's registrations are applied at generation; run them once
|
|
241
|
+
# here so a bad one is a mistake in the block, said where it is written
|
|
242
|
+
graph.registry
|
|
243
|
+
graph
|
|
244
|
+
rescue NameError => e
|
|
245
|
+
# Ruby raises on the argument before the registration is ever called, so
|
|
246
|
+
# the block is the only place that can say why — and in Rails this is
|
|
247
|
+
# the commonest way to meet it. NoMethodError is a NameError too, and
|
|
248
|
+
# means something else entirely.
|
|
249
|
+
raise if e.is_a?(NoMethodError)
|
|
250
|
+
|
|
251
|
+
raise e.class, "#{e.message} — a graph block runs where it is written, so a registration " \
|
|
252
|
+
"in it stands where a top-level one does. #{GraphWeaver::Codegen::AUTOLOAD_HINT} " \
|
|
253
|
+
"Declare graph #{name.inspect} from one.", e.backtrace
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def initialize(name)
|
|
257
|
+
@name = name
|
|
258
|
+
@settings = {}
|
|
259
|
+
# kept as calls rather than applied here: the graph replays them over
|
|
260
|
+
# the top-level registry at generation (see Graph#registry)
|
|
261
|
+
@registrations = []
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
SETTINGS.each do |setting|
|
|
265
|
+
define_method(setting) do |*value|
|
|
266
|
+
return @settings[setting] if value.empty?
|
|
267
|
+
raise ArgumentError, "#{setting} takes one value, got #{value.size}" if value.size > 1
|
|
268
|
+
|
|
269
|
+
@settings[setting] = GraphBuilder.constant_name(setting, value.first)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
REGISTRATIONS.each do |registration|
|
|
274
|
+
define_method(registration) do |*args, **kwargs, &block|
|
|
275
|
+
@registrations << [registration, args, kwargs, block]
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def method_missing(name, *, **, &) = raise(ArgumentError, refusal(name))
|
|
280
|
+
|
|
281
|
+
# Nothing reaches method_missing but a mistake, so the honest answer for
|
|
282
|
+
# every name it would catch is false.
|
|
283
|
+
def respond_to_missing?(name, _private = false) = false
|
|
284
|
+
|
|
285
|
+
# A Module where a constant's name goes says the same thing, and is what
|
|
286
|
+
# `client Billing::CLIENT` reads like. Anything else passes through:
|
|
287
|
+
# a schema is a path, SDL, a class, a Client, or a callable.
|
|
288
|
+
# On the singleton so the define_method setters above can reach it — srb
|
|
289
|
+
# reads a define_method block's self as the class.
|
|
290
|
+
def self.constant_name(setting, value)
|
|
291
|
+
return value unless CONSTANT_SETTINGS.include?(setting)
|
|
292
|
+
|
|
293
|
+
# Generated modules are defined at the top level, where a root anchor
|
|
294
|
+
# says nothing — and `module ::A::B` is not a name const_get can spell,
|
|
295
|
+
# so it used to surface as a verdict on the .graphql file's name.
|
|
296
|
+
if MODULE_SETTINGS.include?(setting) && value.is_a?(String) && value.start_with?("::")
|
|
297
|
+
raise ArgumentError, "#{setting} #{value.inspect}: drop the leading `::` — #{setting} " \
|
|
298
|
+
"names a module generated source defines, and it defines it at the top level either way"
|
|
299
|
+
end
|
|
300
|
+
return value unless value.is_a?(Module)
|
|
301
|
+
|
|
302
|
+
value.name || raise(ArgumentError, "#{setting} needs a constant — generated source has " \
|
|
303
|
+
"to spell it — and #{value.inspect} is anonymous")
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def refusal(name)
|
|
307
|
+
takes = SETTINGS + REGISTRATIONS
|
|
308
|
+
near = GraphWeaver::Internal::Util.did_you_mean(takes.map(&:to_s), name.to_s)
|
|
309
|
+
"#{name} isn't something a graph block takes#{near ? " (did you mean #{near}?)" : ""} — " \
|
|
310
|
+
"graph #{@name.inspect} takes #{takes.join(", ")}"
|
|
311
|
+
end
|
|
312
|
+
private :refusal
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|