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