graph_weaver 0.4.6 → 0.5.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/CHANGELOG.md +1442 -0
- data/Gemfile.lock +23 -23
- data/README.md +115 -96
- data/docs/cassettes.md +93 -46
- data/docs/editors.md +82 -0
- data/docs/errors.md +34 -30
- data/docs/federation.md +521 -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 +300 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +134 -0
- data/graph_weaver.gemspec +19 -3
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +118 -111
- data/lib/graph_weaver/codegen/aliases.rb +223 -0
- data/lib/graph_weaver/codegen/emit.rb +283 -261
- data/lib/graph_weaver/codegen/enum_type.rb +25 -124
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +69 -66
- data/lib/graph_weaver/codegen/type_helpers.rb +140 -0
- data/lib/graph_weaver/codegen.rb +672 -336
- data/lib/graph_weaver/errors.rb +154 -16
- data/lib/graph_weaver/federation.rb +259 -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 +59 -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 +220 -16
- data/lib/graph_weaver/schema_loader.rb +819 -60
- data/lib/graph_weaver/schemas.rb +48 -0
- data/lib/graph_weaver/selection.rb +43 -8
- data/lib/graph_weaver/tasks.rb +220 -22
- data/lib/graph_weaver/testing/cassette.rb +249 -81
- data/lib/graph_weaver/testing/coverage.rb +160 -0
- data/lib/graph_weaver/testing/failure.rb +14 -25
- data/lib/graph_weaver/testing/fake_client.rb +182 -22
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1452 -0
- data/lib/graph_weaver/testing/subgraphs.rb +134 -0
- data/lib/graph_weaver/testing.rb +209 -13
- 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 +416 -172
- metadata +25 -9
- data/CLAUDE.md +0 -69
- data/Makefile +0 -23
- data/NOTES.md +0 -182
- data/PLAN.md +0 -144
data/lib/graph_weaver.rb
CHANGED
|
@@ -5,10 +5,12 @@ require_relative "graph_weaver/logging"
|
|
|
5
5
|
require_relative "graph_weaver/errors"
|
|
6
6
|
require_relative "graph_weaver/hints"
|
|
7
7
|
require_relative "graph_weaver/input_struct"
|
|
8
|
+
require_relative "graph_weaver/query_module"
|
|
8
9
|
require_relative "graph_weaver/response"
|
|
9
10
|
require_relative "graph_weaver/inflect"
|
|
10
11
|
require_relative "graph_weaver/codegen"
|
|
11
12
|
require_relative "graph_weaver/client"
|
|
13
|
+
require_relative "graph_weaver/in_process"
|
|
12
14
|
require_relative "graph_weaver/transport/http"
|
|
13
15
|
require_relative "graph_weaver/retry"
|
|
14
16
|
require_relative "graph_weaver/schema_loader"
|
|
@@ -18,6 +20,14 @@ require_relative "graph_weaver/railtie" if defined?(::Rails::Railtie)
|
|
|
18
20
|
# opt-in extras:
|
|
19
21
|
# require "graph_weaver/transport/faraday" # Faraday transport
|
|
20
22
|
module GraphWeaver
|
|
23
|
+
# The line every generated file opens with (see Codegen::Emit) — the marker
|
|
24
|
+
# that tells generate! which files in the output directory are its to prune.
|
|
25
|
+
GENERATED_HEADER = "# Generated by GraphWeaver"
|
|
26
|
+
|
|
27
|
+
# How far into a file to look for it: the header sits under the `typed:` and
|
|
28
|
+
# `frozen_string_literal:` magic comments, never deeper.
|
|
29
|
+
HEADER_SCAN_LINES = 10
|
|
30
|
+
|
|
21
31
|
class << self
|
|
22
32
|
# A client for one GraphQL server — transport, schema, and scoped
|
|
23
33
|
# scalars in one object (see Client):
|
|
@@ -35,40 +45,113 @@ module GraphWeaver
|
|
|
35
45
|
#
|
|
36
46
|
# GraphWeaver.client = GraphWeaver.new(url, auth: token)
|
|
37
47
|
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
48
|
+
# Anything satisfying the execute contract — a Client, a schema class,
|
|
49
|
+
# a transport, a fake (testing's graphql: tag swaps one in per
|
|
40
50
|
# example). Generated modules resolve per call -> per module
|
|
41
51
|
# (MyQuery.client=) -> baked constant -> here.
|
|
42
52
|
attr_accessor :client
|
|
43
53
|
|
|
44
54
|
# the default client, when one is required
|
|
45
55
|
def client!
|
|
46
|
-
|
|
56
|
+
# in a spec suite this is nearly always a forgotten tag, and "set
|
|
57
|
+
# GraphWeaver.client=" is advice for the wrong file — graph_weaver/rspec
|
|
58
|
+
# being loaded is what says which suggestion is the useful one
|
|
59
|
+
@client or raise Error, "no client configured — " + if defined?(Testing::RSpecIntegration)
|
|
60
|
+
"tag the example graphql: :fake (or :in_process / :router), or build one with graphql_fake"
|
|
61
|
+
else
|
|
62
|
+
"set GraphWeaver.client= or pass a client"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Shape-check a raw response envelope, returning it. Generated
|
|
67
|
+
# from_response is public API taking anything with #to_h, so a malformed
|
|
68
|
+
# body has to brand rather than escape as a raw Sorbet TypeError from a sig
|
|
69
|
+
# (which fires before the struct's own rescue can see it). Lives here rather
|
|
70
|
+
# than unrolled into every generated module.
|
|
71
|
+
# Cast a response's data, keeping the server's own errors on a failure.
|
|
72
|
+
# The common cause of a cast failure is a field that came back null *with
|
|
73
|
+
# a reason attached* — a permission rule, a partial outage — and raising
|
|
74
|
+
# only Sorbet's nil complaint throws that reason away, leaving whoever is
|
|
75
|
+
# on call with a type error and no explanation.
|
|
76
|
+
sig do
|
|
77
|
+
params(struct: T.untyped, data: T.untyped, errors: T::Array[GraphWeaver::GraphQLError])
|
|
78
|
+
.returns(T.untyped)
|
|
79
|
+
end
|
|
80
|
+
def cast_data(struct, data, errors)
|
|
81
|
+
struct.from_h(data)
|
|
82
|
+
rescue GraphWeaver::TypeError => e
|
|
83
|
+
raise if errors.empty?
|
|
84
|
+
|
|
85
|
+
detail = e.message.delete_prefix("failed to cast response into #{struct}: ")
|
|
86
|
+
raise GraphWeaver::TypeError.new(
|
|
87
|
+
struct:,
|
|
88
|
+
message: "#{detail} — the server also reported: #{errors.map(&:message).join("; ")}",
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def check_envelope!(raw, struct)
|
|
93
|
+
unless raw.is_a?(Hash)
|
|
94
|
+
raise GraphWeaver::TypeError.new(struct:, message: "response must be an object, got #{raw.class}")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
%w[data extensions].each do |key|
|
|
98
|
+
value = raw[key]
|
|
99
|
+
next if value.nil? || value.is_a?(Hash)
|
|
100
|
+
|
|
101
|
+
raise GraphWeaver::TypeError.new(struct:, message: "response #{key.inspect} must be an object, got #{value.class}")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
errors = raw["errors"]
|
|
105
|
+
unless errors.nil? || (errors.is_a?(Array) && errors.all?(Hash))
|
|
106
|
+
raise GraphWeaver::TypeError.new(struct:, message: "response \"errors\" must be an array of objects")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
raw
|
|
47
110
|
end
|
|
48
111
|
|
|
49
|
-
# The
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
112
|
+
# The module a .graphql file generates, and the basename of the file it
|
|
113
|
+
# generates into: the camelized file name plus the operation's own word.
|
|
114
|
+
#
|
|
115
|
+
# person.graphql => PersonQuery (person_query.rb)
|
|
116
|
+
# save_list_entry.graphql => SaveListEntryMutation
|
|
117
|
+
# (save_list_entry_mutation.rb)
|
|
118
|
+
#
|
|
119
|
+
# Every naming site goes through here — generate!, parse(path), and
|
|
120
|
+
# load_queries! — so the constant a file produces is the same one
|
|
121
|
+
# whichever door you came in by, and the file it lands in matches it.
|
|
122
|
+
def generated_names(path, source)
|
|
123
|
+
base = File.basename(path, ".*")
|
|
124
|
+
suffix = operation_suffix(source)
|
|
125
|
+
["#{Inflect.camelize(base)}#{suffix}", "#{base}_#{suffix.downcase}.rb"]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# just the module name — see generated_names
|
|
129
|
+
def module_name(path, source) = generated_names(path, source).first
|
|
130
|
+
|
|
131
|
+
# "Mutation" for a mutation document, "Query" for everything else.
|
|
132
|
+
def operation_suffix(source)
|
|
133
|
+
operation = GraphQL.parse(source).definitions
|
|
134
|
+
.grep(GraphQL::Language::Nodes::OperationDefinition).first
|
|
135
|
+
(operation&.operation_type == "mutation") ? "Mutation" : "Query"
|
|
136
|
+
rescue GraphQL::ParseError
|
|
137
|
+
"Query" # unparseable: codegen brands the real error a moment later
|
|
55
138
|
end
|
|
56
139
|
|
|
57
|
-
# Conventional locations
|
|
58
|
-
# locations (a test-only dir, an engine's) can be
|
|
59
|
-
#
|
|
140
|
+
# Conventional locations. Every directory setting is a LIST,
|
|
141
|
+
# factory_bot-style: extra locations (a test-only dir, an engine's) can be
|
|
142
|
+
# appended and every reader walks them all. Entries may be glob patterns,
|
|
143
|
+
# and the generated default already matches per-schema layouts
|
|
144
|
+
# (app/graphql/github/generated). Assigning a String wraps it, so pointing
|
|
145
|
+
# at one directory stays a one-liner:
|
|
60
146
|
#
|
|
61
|
-
#
|
|
62
|
-
# GraphWeaver.generated_paths << "spec/
|
|
63
|
-
# GraphWeaver.queries_paths << "spec/support/graphql/queries"
|
|
147
|
+
# GraphWeaver.queries_paths = "app/graphql/operations"
|
|
148
|
+
# GraphWeaver.generated_paths << "spec/graphql/generated"
|
|
64
149
|
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
|
|
150
|
+
# schema_path is the exception, and singular on purpose: one generate! run
|
|
151
|
+
# reads ONE schema, so a second dump in a list is a file nothing would ever
|
|
152
|
+
# read. A second schema is a second generate! (schema: names it).
|
|
153
|
+
attr_writer :schema_path
|
|
68
154
|
|
|
69
|
-
# Entries may be glob patterns — the generated default also matches
|
|
70
|
-
# per-schema layouts (app/graphql/github/generated). Queries stay
|
|
71
|
-
# single-schema: load_queries! parses everything against one client.
|
|
72
155
|
def queries_paths = @queries_paths ||= ["app/graphql/queries"]
|
|
73
156
|
def generated_paths = @generated_paths ||= ["app/graphql/generated", "app/graphql/*/generated"]
|
|
74
157
|
|
|
@@ -77,65 +160,59 @@ module GraphWeaver
|
|
|
77
160
|
# query stays self-contained.
|
|
78
161
|
def fragments_paths = @fragments_paths ||= ["app/graphql/fragments"]
|
|
79
162
|
|
|
80
|
-
|
|
81
|
-
def
|
|
82
|
-
|
|
163
|
+
# nil restores the default; a String is one entry, not a second spelling
|
|
164
|
+
def queries_paths=(paths)
|
|
165
|
+
@queries_paths = paths && Array(paths)
|
|
166
|
+
end
|
|
83
167
|
|
|
84
|
-
def
|
|
85
|
-
@
|
|
168
|
+
def generated_paths=(paths)
|
|
169
|
+
@generated_paths = paths && Array(paths)
|
|
86
170
|
end
|
|
87
171
|
|
|
88
|
-
def
|
|
89
|
-
@
|
|
172
|
+
def fragments_paths=(paths)
|
|
173
|
+
@fragments_paths = paths && Array(paths)
|
|
90
174
|
end
|
|
91
175
|
|
|
92
176
|
def schema_path = @schema_path || "app/graphql/schema.json"
|
|
93
177
|
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
|
|
98
|
-
# GithubUnions); the conventional layout (and anything unrecognizable)
|
|
99
|
-
# stays GraphQLInputs / GraphQLUnions.
|
|
100
|
-
attr_writer :inputs_module, :unions_module
|
|
101
|
-
|
|
102
|
-
def inputs_module(output = generated_path)
|
|
103
|
-
@inputs_module || derive_module("Inputs", output)
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def unions_module(output = generated_path)
|
|
107
|
-
@unions_module || derive_module("Unions", output)
|
|
108
|
-
end
|
|
178
|
+
# Set by every graph_weaver rake task: those tasks WRITE the generated
|
|
179
|
+
# files, so loading them first lets a stale one block its own repair.
|
|
180
|
+
# None of them needs the modules loaded.
|
|
181
|
+
attr_accessor :skip_generated_load
|
|
109
182
|
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
def
|
|
113
|
-
|
|
114
|
-
segments.pop if segments.last == "generated"
|
|
115
|
-
parent = segments.last.to_s
|
|
116
|
-
if parent.match?(/\A[a-zA-Z]\w*\z/) && !%w[graphql app lib spec support test].include?(parent)
|
|
117
|
-
"#{Inflect.camelize(parent)}#{suffix}"
|
|
118
|
-
else
|
|
119
|
-
"GraphQL#{suffix}"
|
|
120
|
-
end
|
|
183
|
+
# Every query document under these directories, sorted — the files
|
|
184
|
+
# generate!, verify_generated!, check_queries and load_queries! all read.
|
|
185
|
+
def query_files(paths = queries_paths)
|
|
186
|
+
Array(paths).flat_map { |dir| Dir[File.join(dir, Codegen::DOCUMENT_GLOB)].sort }
|
|
121
187
|
end
|
|
122
|
-
private :derive_module
|
|
123
188
|
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
189
|
+
# The name of the shared module — the types that live once per schema
|
|
190
|
+
# (input types, enums, unions hoisted from shared fragments) and are
|
|
191
|
+
# aliased into every query module that touches them. Constant, not derived
|
|
192
|
+
# from where you put the files: set it globally, or pass types_module: per
|
|
193
|
+
# generate!. A multi-schema layout names it in the same initializer that
|
|
194
|
+
# sets its paths.
|
|
195
|
+
attr_writer :types_module
|
|
196
|
+
|
|
197
|
+
def types_module = @types_module || "GraphQLTypes"
|
|
198
|
+
|
|
199
|
+
# Generate every query in a directory — .graphql/.gql, subdirectories
|
|
200
|
+
# included — into checked-in Ruby files. Paths default to the conventions
|
|
201
|
+
# above; schema: defaults to the dump at schema_path (any supported
|
|
202
|
+
# extension), and also takes a Client (its schema — the console object,
|
|
203
|
+
# no dump needed):
|
|
127
204
|
#
|
|
128
|
-
# GraphWeaver.generate! #
|
|
205
|
+
# GraphWeaver.generate! # queries_paths -> generated_paths.first
|
|
129
206
|
#
|
|
130
207
|
# person.graphql => person_query.rb defining PersonQuery. Returns the
|
|
131
|
-
# written paths.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
plan = generation_plan(queries:, schema:, client:,
|
|
208
|
+
# written paths. Generated files the plan no longer produces are deleted
|
|
209
|
+
# (see #orphaned), so renaming or dropping a .graphql leaves nothing
|
|
210
|
+
# behind. Pair with a freshness spec (docs/generated_modules.md).
|
|
211
|
+
def generate!(schema: nil, queries: queries_paths, output: generated_paths.first, client: nil,
|
|
212
|
+
types_module: nil)
|
|
213
|
+
schema = schema ? schema_for(schema) : locate_schema!
|
|
214
|
+
|
|
215
|
+
plan = generation_plan(queries:, schema:, client:, types_module:)
|
|
139
216
|
written = plan.map do |filename, source|
|
|
140
217
|
target = File.join(output, filename)
|
|
141
218
|
FileUtils.mkdir_p(File.dirname(target))
|
|
@@ -144,9 +221,7 @@ module GraphWeaver
|
|
|
144
221
|
target
|
|
145
222
|
end
|
|
146
223
|
|
|
147
|
-
|
|
148
|
-
# linger as a stale file — inputs/ and unions.rb are wholly generated
|
|
149
|
-
(shared_artifacts(output) - written).each do |orphan|
|
|
224
|
+
orphaned(output, written).each do |orphan|
|
|
150
225
|
File.delete(orphan)
|
|
151
226
|
log(:info) { "pruned #{orphan}" }
|
|
152
227
|
end
|
|
@@ -154,32 +229,48 @@ module GraphWeaver
|
|
|
154
229
|
written
|
|
155
230
|
end
|
|
156
231
|
|
|
157
|
-
#
|
|
158
|
-
#
|
|
159
|
-
|
|
160
|
-
|
|
232
|
+
# Generated files under output the current plan no longer produces — a
|
|
233
|
+
# query renamed or deleted, a type dropped from the schema, a union no
|
|
234
|
+
# longer hoisted. Left alone they'd keep being required by
|
|
235
|
+
# load_generated!, resolving against a query that no longer exists.
|
|
236
|
+
def orphaned(output, produced)
|
|
237
|
+
current = produced.map { |path| File.expand_path(path) }
|
|
238
|
+
generated_files(output).reject { |path| current.include?(File.expand_path(path)) }
|
|
161
239
|
end
|
|
162
|
-
private :
|
|
240
|
+
private :orphaned
|
|
241
|
+
|
|
242
|
+
# Every .rb under output that GraphWeaver wrote, identified by the header
|
|
243
|
+
# it emits. The header — not a *_query.rb glob — is what makes pruning
|
|
244
|
+
# safe: this is a real directory, and a hand-written file in it must
|
|
245
|
+
# survive regeneration.
|
|
246
|
+
def generated_files(output)
|
|
247
|
+
Dir[File.join(output, "**/*.rb")].sort.select do |path|
|
|
248
|
+
File.foreach(path).first(HEADER_SCAN_LINES).any? { |line| line.start_with?(GENERATED_HEADER) }
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
private :generated_files
|
|
163
252
|
|
|
164
253
|
# The freshness guard: raise unless every generated file matches what
|
|
165
|
-
# the current schema + queries + scalar registrations would produce
|
|
166
|
-
#
|
|
254
|
+
# the current schema + queries + scalar registrations would produce —
|
|
255
|
+
# counting a generated file the plan no longer produces as stale, so a
|
|
256
|
+
# deleted query can't leave a live module behind. One line in a spec, or
|
|
257
|
+
# `rake graph_weaver:verify` in CI:
|
|
167
258
|
#
|
|
168
259
|
# it "generated queries are current" do
|
|
169
260
|
# GraphWeaver.verify_generated!
|
|
170
261
|
# end
|
|
171
|
-
def verify_generated!(schema: nil, queries:
|
|
172
|
-
|
|
173
|
-
schema
|
|
174
|
-
|
|
175
|
-
unions_module ||= self.unions_module(output)
|
|
176
|
-
plan = generation_plan(queries:, schema:, client:, inputs_module:, unions_module:)
|
|
262
|
+
def verify_generated!(schema: nil, queries: queries_paths, output: generated_paths.first, client: nil,
|
|
263
|
+
types_module: nil)
|
|
264
|
+
schema = schema ? schema_for(schema) : locate_schema!
|
|
265
|
+
plan = generation_plan(queries:, schema:, client:, types_module:)
|
|
177
266
|
stale = plan.filter_map do |filename, source|
|
|
178
267
|
target = File.join(output, filename)
|
|
179
|
-
|
|
268
|
+
# git's autocrlf rewrites line endings on checkout — a Windows working
|
|
269
|
+
# copy is not stale generated code, so don't fail CI over it
|
|
270
|
+
target unless File.exist?(target) && File.read(target).gsub("\r\n", "\n") == source.gsub("\r\n", "\n")
|
|
180
271
|
end
|
|
181
|
-
# strays: a
|
|
182
|
-
stale +=
|
|
272
|
+
# strays: a generated file the current schema + queries no longer produce
|
|
273
|
+
stale += orphaned(output, plan.map { |filename, _| File.join(output, filename) })
|
|
183
274
|
|
|
184
275
|
unless stale.empty?
|
|
185
276
|
raise Error, "stale generated queries — regenerate (rake graph_weaver:generate): #{stale.join(", ")}"
|
|
@@ -188,6 +279,140 @@ module GraphWeaver
|
|
|
188
279
|
true
|
|
189
280
|
end
|
|
190
281
|
|
|
282
|
+
# Which checked-in queries no longer validate — breaking-change
|
|
283
|
+
# detection scoped to the operations you actually ship. Reports rather
|
|
284
|
+
# than raising, keyed by file, JSON-ready like every #to_h here:
|
|
285
|
+
#
|
|
286
|
+
# GraphWeaver.check_queries
|
|
287
|
+
# # => { "app/graphql/queries/person.graphql" =>
|
|
288
|
+
# # [{ "message" => "Field 'titel' doesn't exist on type 'Person'",
|
|
289
|
+
# # "line" => 4, "column" => 5 }] }
|
|
290
|
+
#
|
|
291
|
+
# Empty means every query validates. schema: defaults to the server as
|
|
292
|
+
# it is now — a FRESH introspection of the url the dump records, or the
|
|
293
|
+
# live schema class when the app default runs in-process — and the dump
|
|
294
|
+
# is left alone; pass schema: and nothing touches the network.
|
|
295
|
+
#
|
|
296
|
+
# When that dump is a composed supergraph, an error naming a type is
|
|
297
|
+
# branded with the subgraphs behind it — "…on type 'Product'
|
|
298
|
+
# (products, reviews)", plus a "subgraphs" key — since knowing whose
|
|
299
|
+
# code to look at is half the answer. A plain schema is unaffected.
|
|
300
|
+
#
|
|
301
|
+
# A different question from verify_generated!, which asks whether the
|
|
302
|
+
# committed Ruby matches the committed schema. `rake
|
|
303
|
+
# graph_weaver:queries:check` prints this and exits non-zero.
|
|
304
|
+
def check_queries(schema: nil, queries: queries_paths, fragments: fragments_paths)
|
|
305
|
+
# subgraph branding comes from the local supergraph dump, so a caller
|
|
306
|
+
# supplying its own schema opts out of it
|
|
307
|
+
table = schema ? nil : checked_routing_table
|
|
308
|
+
schema = schema ? schema_for(schema) : refreshed_schema
|
|
309
|
+
shared = Codegen.load_fragments(fragments)
|
|
310
|
+
|
|
311
|
+
query_files(queries).each_with_object({}) do |path, failures|
|
|
312
|
+
errors = validation_errors(schema, File.read(path), shared, table)
|
|
313
|
+
failures[path] = errors if errors.any?
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# The routing table behind the schema check_queries is about to use,
|
|
318
|
+
# when there is one: a composed supergraph dump says who resolves what,
|
|
319
|
+
# so a validation error can name the subgraph whose code to look at. nil
|
|
320
|
+
# for every other source — a plain schema is entirely unaffected — and
|
|
321
|
+
# nil when a live schema class is what gets checked, since the dump then
|
|
322
|
+
# isn't what the errors came from.
|
|
323
|
+
def checked_routing_table
|
|
324
|
+
return if live_schema
|
|
325
|
+
|
|
326
|
+
path = SchemaLoader.locate_path
|
|
327
|
+
return unless path&.end_with?(".graphql", ".gql")
|
|
328
|
+
|
|
329
|
+
sdl = File.read(path)
|
|
330
|
+
SchemaLoader.routing_table(sdl) if SchemaLoader.federation_sdl?(sdl)
|
|
331
|
+
end
|
|
332
|
+
private :checked_routing_table
|
|
333
|
+
|
|
334
|
+
# The schema check_queries defaults to: the server as it is now. Over a
|
|
335
|
+
# socket that's a fresh introspection of the url the local dump recorded,
|
|
336
|
+
# so no refresh step (and no rewritten dump) is needed first. In-process
|
|
337
|
+
# it's the live schema class — for an app that IS the server, a dump is a
|
|
338
|
+
# snapshot of its own code, and checking against it reports phantom
|
|
339
|
+
# errors about a field you just added. Dumps with no url and no live
|
|
340
|
+
# class — hand-written SDL, a composed supergraph — have nothing to
|
|
341
|
+
# re-read, so they're checked as they are.
|
|
342
|
+
def refreshed_schema
|
|
343
|
+
live = live_schema
|
|
344
|
+
return live if live
|
|
345
|
+
|
|
346
|
+
# locate_schema! raises the conventional "no schema dump" message
|
|
347
|
+
path = SchemaLoader.locate_path or locate_schema!
|
|
348
|
+
return SchemaLoader.load(path) unless SchemaLoader.provenance(path)&.key?("url")
|
|
349
|
+
|
|
350
|
+
# source_transport rather than one built here: it reads the auth ENV var
|
|
351
|
+
# the dump named, so `--auth MY_TOKEN` reaches this path too
|
|
352
|
+
SchemaLoader.introspect(SchemaLoader.source_transport(path))
|
|
353
|
+
end
|
|
354
|
+
private :refreshed_schema
|
|
355
|
+
|
|
356
|
+
# The graphql-ruby schema class the app default executes against, when it
|
|
357
|
+
# runs in-process — a Client wrapping one, or the class in the slot bare.
|
|
358
|
+
# nil for every network client. Not memoized: in dev the class object is
|
|
359
|
+
# replaced on reload. (Public because testing's :in_process mode asks:
|
|
360
|
+
# a client already running in-process names its own schema class.)
|
|
361
|
+
def live_schema
|
|
362
|
+
# through #transport, not #schema: a url client's #schema introspects,
|
|
363
|
+
# so asking it would answer this question over the network
|
|
364
|
+
target = client.is_a?(Client) ? client.transport : client
|
|
365
|
+
target = target.schema if target.is_a?(InProcess)
|
|
366
|
+
target if target.is_a?(Class) && target <= GraphQL::Schema
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# One query's schema-validation errors as JSON-ready hashes, with the
|
|
370
|
+
# source position graphql-ruby reports. Unparseable counts as an error
|
|
371
|
+
# too — it doesn't validate either, and inline_fragments (which parses
|
|
372
|
+
# first) has already branded it with its position.
|
|
373
|
+
def validation_errors(schema, source, shared, table = nil)
|
|
374
|
+
# path omitted: the caller keys the report by file, so branding the
|
|
375
|
+
# message with it too would just print the path twice
|
|
376
|
+
schema.validate(Codegen.inline_fragments(source, shared)).map do |error|
|
|
377
|
+
detail = error.to_h
|
|
378
|
+
location = detail["locations"]&.first || {}
|
|
379
|
+
subgraphs = table ? attribute(table, detail["extensions"]) : []
|
|
380
|
+
entry = {
|
|
381
|
+
"message" => subgraphs.empty? ? error.message : "#{error.message} (#{subgraphs.join(", ")})",
|
|
382
|
+
"line" => location["line"],
|
|
383
|
+
"column" => location["column"],
|
|
384
|
+
}
|
|
385
|
+
subgraphs.empty? ? entry : entry.merge("subgraphs" => subgraphs)
|
|
386
|
+
end
|
|
387
|
+
rescue GraphWeaver::ValidationError => e
|
|
388
|
+
# an unparseable query: codegen folds the position (and the file) into
|
|
389
|
+
# the message, and this report keeps them separate — same splitter the
|
|
390
|
+
# rendered error uses, so the two can't drift apart
|
|
391
|
+
e.errors.map do |detail|
|
|
392
|
+
_path, _position, message = ValidationError.split(detail)
|
|
393
|
+
detail.transform_keys(&:to_s).merge("message" => message)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
private :validation_errors
|
|
397
|
+
|
|
398
|
+
# Which subgraphs a validation error is about, on a federated schema:
|
|
399
|
+
# "Field 'weight' doesn't exist on type 'Product'" is much less useful
|
|
400
|
+
# than the same line plus "(products)" — whose code to look at, whose
|
|
401
|
+
# team to talk to. graphql-ruby reports the coordinate structurally, so
|
|
402
|
+
# this is a lookup rather than message parsing. Both halves of the
|
|
403
|
+
# coordinate are required: an argument error reports typeName "Field"
|
|
404
|
+
# (the AST node kind, not a type), and looking that up would attribute
|
|
405
|
+
# confidently and wrongly.
|
|
406
|
+
def attribute(table, extensions)
|
|
407
|
+
return [] unless extensions
|
|
408
|
+
|
|
409
|
+
type_name, field_name = extensions.values_at("typeName", "fieldName")
|
|
410
|
+
return [] unless type_name && field_name
|
|
411
|
+
|
|
412
|
+
table.responsible(type_name, field_name)
|
|
413
|
+
end
|
|
414
|
+
private :attribute
|
|
415
|
+
|
|
191
416
|
# Load the generated modules — one line in an initializer or spec
|
|
192
417
|
# helper (loading happens only when you call this; skip it and
|
|
193
418
|
# require files yourself if you'd rather):
|
|
@@ -201,11 +426,33 @@ module GraphWeaver
|
|
|
201
426
|
def load_generated!(path = nil)
|
|
202
427
|
paths = path ? [path] : generated_paths
|
|
203
428
|
files = paths.flat_map { |dir| Dir[File.join(dir, "**/*.rb")].sort }.uniq
|
|
204
|
-
files.each
|
|
429
|
+
files.each do |file|
|
|
430
|
+
require File.expand_path(file)
|
|
431
|
+
rescue NameError => e
|
|
432
|
+
# a dropped extend_type leaves this include dangling; say so here,
|
|
433
|
+
# because the raw NameError points at generated code and names no fix
|
|
434
|
+
helper = e.message[/GraphWeaver::TypeHelpers::(\w+)/, 1] or raise
|
|
435
|
+
raise Error, "#{file} includes GraphWeaver::TypeHelpers::#{helper}, but nothing registers it — " \
|
|
436
|
+
"the extend_type(#{helper.inspect}) it was generated from is gone. Re-add that registration, " \
|
|
437
|
+
"or regenerate without it: rake graph_weaver:generate"
|
|
438
|
+
end
|
|
205
439
|
log(:info) { "loaded #{files.size} generated module(s) from #{paths.join(", ")}" }
|
|
206
440
|
files
|
|
207
441
|
end
|
|
208
442
|
|
|
443
|
+
# Anywhere GraphWeaver takes schema:, a Client stands for its schema — so
|
|
444
|
+
# the console object and the rake task point at the same thing. A path
|
|
445
|
+
# (String or Pathname) or SDL loads like it does everywhere else in the
|
|
446
|
+
# library; without that it reached `schema.validate` as itself and failed
|
|
447
|
+
# as `undefined method 'validate' for an instance of String`.
|
|
448
|
+
def schema_for(source)
|
|
449
|
+
return source.schema if source.is_a?(Client)
|
|
450
|
+
return SchemaLoader.load(source) if source.is_a?(String) || source.respond_to?(:to_path)
|
|
451
|
+
|
|
452
|
+
source
|
|
453
|
+
end
|
|
454
|
+
private :schema_for
|
|
455
|
+
|
|
209
456
|
# the conventional schema dump, required
|
|
210
457
|
def locate_schema!
|
|
211
458
|
SchemaLoader.locate or raise Error,
|
|
@@ -213,65 +460,64 @@ module GraphWeaver
|
|
|
213
460
|
end
|
|
214
461
|
private :locate_schema!
|
|
215
462
|
|
|
216
|
-
# (filename, source) per artifact.
|
|
217
|
-
#
|
|
218
|
-
#
|
|
219
|
-
#
|
|
220
|
-
#
|
|
221
|
-
#
|
|
222
|
-
|
|
223
|
-
|
|
463
|
+
# (filename, source) per artifact. Types a schema shares across queries —
|
|
464
|
+
# input types, schema enums, and each named shared fragment spread as a
|
|
465
|
+
# whole-union field — are emitted once into the shared module, with query
|
|
466
|
+
# modules aliasing what they use. That's the difference between hundreds of
|
|
467
|
+
# duplicated bool_exp structs (or one Ruby class per query for the same
|
|
468
|
+
# schema enum) and one copy per schema. (Single-query parse inlines
|
|
469
|
+
# everything — there's no cross-query set to share against.)
|
|
470
|
+
def generation_plan(queries:, schema:, client:, types_module: nil, fragments: fragments_paths)
|
|
471
|
+
types_module ||= self.types_module
|
|
224
472
|
used = { inputs: [], enums: [], mapped: [] }
|
|
225
473
|
used_unions = []
|
|
226
474
|
shared = Codegen.load_fragments(fragments)
|
|
227
475
|
|
|
228
|
-
|
|
229
|
-
|
|
476
|
+
seen = {} # module name => the file that produced it, for the collision message
|
|
477
|
+
|
|
478
|
+
plan = query_files(queries).map do |path|
|
|
230
479
|
source = File.read(path)
|
|
480
|
+
name, filename = generated_names(path, source)
|
|
481
|
+
if (earlier = seen[name])
|
|
482
|
+
raise Error, "duplicate query module #{name} — #{earlier} and #{path} both generate it; " \
|
|
483
|
+
"the module name comes from the file name alone (directories don't namespace it), so rename one"
|
|
484
|
+
end
|
|
485
|
+
seen[name] = path
|
|
486
|
+
|
|
231
487
|
codegen = Codegen.new(
|
|
232
488
|
schema:,
|
|
233
|
-
query: Codegen.inline_fragments(source, shared),
|
|
234
|
-
module_name:
|
|
489
|
+
query: Codegen.inline_fragments(source, shared, path),
|
|
490
|
+
module_name: name,
|
|
235
491
|
client:,
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
492
|
+
types_namespace: types_module,
|
|
493
|
+
hoistable_unions: Codegen.shared_fragment_spreads(source, shared, path),
|
|
494
|
+
path:,
|
|
239
495
|
)
|
|
240
496
|
out = codegen.generate
|
|
241
497
|
codegen.variable_type_names.each { |kind, names| used[kind] |= names }
|
|
242
498
|
used_unions |= codegen.used_union_names
|
|
243
|
-
[
|
|
499
|
+
[filename, out]
|
|
244
500
|
end
|
|
245
501
|
|
|
246
|
-
if
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
if unions_module && used_unions.any?
|
|
255
|
-
unions = Codegen.generate_unions(
|
|
256
|
-
schema:, module_name: unions_module, fragments: shared, names: used_unions,
|
|
257
|
-
)
|
|
258
|
-
plan = unions.to_a + plan
|
|
502
|
+
if used_unions.any? || used.values.any?(&:any?)
|
|
503
|
+
codegen = Codegen.new(schema:, query: "", module_name: types_module)
|
|
504
|
+
plan = codegen.generate_types(
|
|
505
|
+
inputs: used[:inputs], enums: used[:enums] + used[:mapped],
|
|
506
|
+
unions: used_unions, fragments: shared,
|
|
507
|
+
).to_a + plan
|
|
259
508
|
end
|
|
260
509
|
|
|
261
510
|
plan
|
|
262
511
|
end
|
|
263
512
|
private :generation_plan
|
|
264
513
|
|
|
265
|
-
#
|
|
266
|
-
#
|
|
267
|
-
# generate
|
|
514
|
+
# coerce: true for every scalar that doesn't say coerce: itself —
|
|
515
|
+
# the same switch at global scope, resolved lazily at generation time
|
|
516
|
+
# (so set it any time before you generate, no ordering dance):
|
|
268
517
|
#
|
|
269
518
|
# GraphWeaver.auto_coerce = true
|
|
270
519
|
#
|
|
271
|
-
#
|
|
272
|
-
# and any scalar with a full cast/serialize pair (Date, your Money)
|
|
273
|
-
# accepts its raw wire form. An explicit coerce: true/false/Symbol on
|
|
274
|
-
# a registration always wins.
|
|
520
|
+
# An explicit coerce: on a registration always wins.
|
|
275
521
|
attr_accessor :auto_coerce
|
|
276
522
|
|
|
277
523
|
# Whether generated modules/structs emit `extend T::Sig` (so `sig`
|
|
@@ -317,9 +563,10 @@ module GraphWeaver
|
|
|
317
563
|
# (a String or Array) names files the generated code needs — validated,
|
|
318
564
|
# and actually required to confirm it resolves when type: is a real class.
|
|
319
565
|
# coerce: true makes a variable of this scalar accept the value OR its
|
|
320
|
-
# raw input (e.g. "12.00"),
|
|
321
|
-
#
|
|
322
|
-
#
|
|
566
|
+
# raw input (e.g. "12.00"), normalizing the latter before serializing —
|
|
567
|
+
# it raises on bad input, so some safety survives; GraphWeaver.auto_coerce
|
|
568
|
+
# is the same switch for every scalar at once. Built-in scalars are
|
|
569
|
+
# pre-registered the same way, so this also overrides them.
|
|
323
570
|
#
|
|
324
571
|
# Pass a `Type.field` coordinate instead of a scalar name to override just
|
|
325
572
|
# that one field — so the same scalar can deserialize as different Ruby
|
|
@@ -342,15 +589,9 @@ module GraphWeaver
|
|
|
342
589
|
# renames, fallback: absorbs unknown wire values on cast (inputs stay
|
|
343
590
|
# strict), requires: names files the generated code should require.
|
|
344
591
|
# Generation fails naming any schema value that doesn't resolve —
|
|
345
|
-
# exhaustiveness checked ahead of runtime.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
Codegen.register_enum(graphql_name, type, map:, fallback:, requires:)
|
|
349
|
-
end
|
|
350
|
-
|
|
351
|
-
# Bulk, inference-only form: register_enums("Species" => PetKind, ...)
|
|
352
|
-
def register_enums(mappings)
|
|
353
|
-
Codegen.register_enums(mappings)
|
|
592
|
+
# exhaustiveness checked ahead of runtime.
|
|
593
|
+
def register_enum(graphql_name, type, positional_map = nil, map: nil, fallback: nil, requires: nil)
|
|
594
|
+
Codegen.register_enum(graphql_name, type, positional_map, map:, fallback:, requires:)
|
|
354
595
|
end
|
|
355
596
|
|
|
356
597
|
# Include app-owned helper modules into every struct generated from a
|
|
@@ -368,65 +609,68 @@ module GraphWeaver
|
|
|
368
609
|
# def display_name = "#{name} the pet"
|
|
369
610
|
# end
|
|
370
611
|
#
|
|
371
|
-
# Additive
|
|
372
|
-
# client.extend_type scopes to one client.
|
|
612
|
+
# Additive — repeated registrations stack.
|
|
373
613
|
def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
|
|
374
614
|
Codegen.extend_type(graphql_name, *mixins, requires:, **kw, &block)
|
|
375
615
|
end
|
|
376
616
|
|
|
377
|
-
#
|
|
378
|
-
#
|
|
379
|
-
# (
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
# Empty the scalar registry entirely, built-ins included (see
|
|
385
|
-
# reset_scalars! to restore the defaults).
|
|
386
|
-
def clear_scalars!
|
|
387
|
-
Codegen.clear_scalars!
|
|
617
|
+
# Every registry back to its starting state: built-in scalars restored,
|
|
618
|
+
# enum mappings and type helpers dropped — the clean slate between
|
|
619
|
+
# tests. (One registry at a time is a Codegen call:
|
|
620
|
+
# GraphWeaver::Codegen.reset_enums!, .reset_scalars!, .clear_scalars!,
|
|
621
|
+
# .reset_type_helpers!)
|
|
622
|
+
def reset_registrations!
|
|
623
|
+
Codegen.reset_registrations!
|
|
388
624
|
end
|
|
389
625
|
|
|
390
626
|
# Parse a query into a typed query module:
|
|
391
627
|
#
|
|
392
628
|
# PersonQuery = GraphWeaver.parse(schema:, query: "queries/person.graphql")
|
|
393
629
|
#
|
|
394
|
-
#
|
|
395
|
-
#
|
|
396
|
-
#
|
|
397
|
-
#
|
|
398
|
-
#
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
630
|
+
# schema: is a graphql-ruby schema or a Client (its schema, and its
|
|
631
|
+
# transport as the module's default). query is a .graphql/.gql path (module
|
|
632
|
+
# name derived from the file name and the operation — see #module_name) or
|
|
633
|
+
# a raw query string (name derived from the operation name, falling back to
|
|
634
|
+
# "Query" for anonymous operations — collisions are impossible since each
|
|
635
|
+
# parse gets its own container). Pass name: to override, client: to bake
|
|
636
|
+
# the module's default client/transport.
|
|
637
|
+
def parse(schema:, query:, name: nil, client: nil, fragments: fragments_paths)
|
|
638
|
+
client ||= schema if schema.is_a?(Client)
|
|
639
|
+
schema = schema_for(schema)
|
|
640
|
+
# Rails.root.join(...) hands you a Pathname, and to_path is the
|
|
641
|
+
# ecosystem's "I am a path" — the same conversion schema: gets through
|
|
642
|
+
# SchemaLoader.load. Without it end_with? below is a NoMethodError.
|
|
643
|
+
query = query.to_path if query.respond_to?(:to_path)
|
|
644
|
+
path = query if query.end_with?(".graphql", ".gql")
|
|
645
|
+
if path
|
|
646
|
+
query = File.read(path)
|
|
647
|
+
name ||= module_name(path, query)
|
|
404
648
|
end
|
|
405
|
-
query = Codegen.inline_fragments(query, Codegen.load_fragments(fragments))
|
|
649
|
+
query = Codegen.inline_fragments(query, Codegen.load_fragments(fragments), path)
|
|
406
650
|
|
|
407
|
-
Codegen.parse(schema:, query:, module_name: name, client:,
|
|
651
|
+
Codegen.parse(schema:, query:, module_name: name, client:, path:)
|
|
408
652
|
end
|
|
409
653
|
|
|
410
654
|
# One-shot dynamic execution — a throwaway client, no build step:
|
|
411
655
|
#
|
|
412
|
-
# GraphWeaver.
|
|
413
|
-
# GraphWeaver.
|
|
656
|
+
# GraphWeaver.run(schema, "query($id: ID!) { ... }", id: "1") # => Response
|
|
657
|
+
# GraphWeaver.run!(url, "query { viewer { login } }") # => Result (or raise)
|
|
414
658
|
#
|
|
415
659
|
# The first argument is a url or schema source, exactly as
|
|
416
|
-
# GraphWeaver.new; this is Client#
|
|
660
|
+
# GraphWeaver.new; this is Client#run on a client you don't keep.
|
|
417
661
|
# (A url source introspects the schema on every call — keep a client
|
|
418
662
|
# for more than one query.) Variables are plain kwargs, as on a
|
|
419
|
-
# generated module (nothing reserved).
|
|
420
|
-
#
|
|
421
|
-
#
|
|
422
|
-
def
|
|
423
|
-
client = source.is_a?(Client) ? source :
|
|
424
|
-
client.
|
|
663
|
+
# generated module (nothing reserved). run returns the Response
|
|
664
|
+
# envelope, run! the typed result, raising QueryError on top-level
|
|
665
|
+
# errors.
|
|
666
|
+
def run(source, query, **variables)
|
|
667
|
+
client = source.is_a?(Client) ? source : new(source)
|
|
668
|
+
client.run(query, **variables)
|
|
425
669
|
end
|
|
426
670
|
|
|
427
|
-
#
|
|
428
|
-
def
|
|
429
|
-
|
|
671
|
+
# run + data! — the typed result, or a raised QueryError. See run.
|
|
672
|
+
def run!(source, query, **variables)
|
|
673
|
+
run(source, query, **variables).data!
|
|
430
674
|
end
|
|
431
675
|
end
|
|
432
676
|
end
|