graph_weaver 0.6.0 → 0.7.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 +1470 -1
- data/Gemfile +8 -0
- data/Gemfile.lock +151 -2
- data/README.md +21 -7
- data/docs/alternatives.md +201 -0
- data/docs/cassettes.md +17 -1
- data/docs/errors.md +382 -17
- data/docs/federation.md +469 -63
- data/docs/generated_modules.md +231 -15
- data/docs/getting_started.md +498 -105
- data/docs/i18n.md +234 -0
- data/docs/logging.md +160 -24
- data/docs/real_world.md +32 -4
- data/docs/scalars.md +286 -57
- data/docs/testing.md +458 -59
- data/docs/transports.md +164 -19
- data/docs/upgrading.md +330 -5
- data/graph_weaver.gemspec +7 -0
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +47 -10
- 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 +218 -59
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +408 -206
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/errors.rb +264 -34
- data/lib/graph_weaver/federation.rb +119 -26
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +17 -11
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +78 -0
- data/lib/graph_weaver/internal/headers.rb +51 -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 +43 -4
- data/lib/graph_weaver/internal.rb +183 -1
- data/lib/graph_weaver/log_subscriber.rb +66 -0
- data/lib/graph_weaver/logging.rb +136 -12
- data/lib/graph_weaver/query_module.rb +36 -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 +33 -5
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +221 -49
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +6 -5
- data/lib/graph_weaver/testing/endpoint.rb +106 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +133 -44
- data/lib/graph_weaver/testing/router.rb +58 -11
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +46 -4
- data/lib/graph_weaver/transport.rb +109 -26
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +490 -116
- metadata +56 -1
|
@@ -69,10 +69,86 @@ module GraphWeaver
|
|
|
69
69
|
create_file "graphql.config.yml", editor_config
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
+
# Generated files are machine-written and say "do not edit", but plain
|
|
73
|
+
# `rubocop` still fires Style/Documentation, Style/ClassAndModuleChildren
|
|
74
|
+
# and Metrics/* on every one of them. Only an app that already lints is
|
|
75
|
+
# touched: writing a .rubocop.yml would turn rubocop on for a project
|
|
76
|
+
# that never asked for it.
|
|
77
|
+
def exclude_generated_from_rubocop
|
|
78
|
+
return unless File.exist?(rubocop_config)
|
|
79
|
+
|
|
80
|
+
body = File.read(rubocop_config)
|
|
81
|
+
# already excluded — a re-run, or done by hand
|
|
82
|
+
globs = generated_globs.reject { |glob| body.include?(glob) }
|
|
83
|
+
return if globs.empty?
|
|
84
|
+
|
|
85
|
+
entries = globs.map { |glob| " - #{glob.inspect}" }.join("\n")
|
|
86
|
+
reason =
|
|
87
|
+
if body.match?(/^AllCops:/)
|
|
88
|
+
# rubocop takes the LAST of two duplicate keys, so appending a second
|
|
89
|
+
# AllCops: would replace the app's own rather than add to it
|
|
90
|
+
"sets AllCops already, and a second one would replace it rather than merge"
|
|
91
|
+
elsif yaml_documents(body) > 1
|
|
92
|
+
# rubocop reads only the first document, so the append lands where
|
|
93
|
+
# nothing will ever read it
|
|
94
|
+
"holds more than one YAML document, and rubocop reads only the first"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Name the lines instead of guessing where inside theirs they belong.
|
|
98
|
+
if reason
|
|
99
|
+
say <<~TEXT
|
|
100
|
+
|
|
101
|
+
#{RUBOCOP_CONFIG} #{reason} — so add this under AllCops/Exclude:
|
|
102
|
+
|
|
103
|
+
#{entries}
|
|
104
|
+
TEXT
|
|
105
|
+
else
|
|
106
|
+
# inherit_mode is what makes this an addition: rubocop REPLACES an
|
|
107
|
+
# Exclude array on merge, so without it the block below wipes the
|
|
108
|
+
# effective list — rubocop's own vendor/node_modules/tmp defaults
|
|
109
|
+
# included, along with any Exclude reaching here through inherit_from.
|
|
110
|
+
append_to_file RUBOCOP_CONFIG, <<~YAML + entries + "\n"
|
|
111
|
+
|
|
112
|
+
# Machine-written by `rake graph_weaver:generate` — not yours to style.
|
|
113
|
+
AllCops:
|
|
114
|
+
inherit_mode:
|
|
115
|
+
merge:
|
|
116
|
+
- Exclude
|
|
117
|
+
Exclude:
|
|
118
|
+
YAML
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The `graphql:` tags need this require, and it has to be somewhere
|
|
123
|
+
# rspec actually loads. A spec/support file is not: rspec-rails ships
|
|
124
|
+
# the spec/support glob commented out, so the require sat there doing
|
|
125
|
+
# nothing and a tagged example silently ran against the real client.
|
|
126
|
+
def wire_rspec
|
|
127
|
+
helper = RSPEC_HELPERS.find { |path| File.exist?(File.join(GraphWeaver.root, path)) }
|
|
128
|
+
|
|
129
|
+
unless helper
|
|
130
|
+
say "\nTesting: add `#{RSPEC_REQUIRE}` to your spec helper for the " \
|
|
131
|
+
"`graphql:` tags (docs/testing.md)."
|
|
132
|
+
return
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
body = File.read(File.join(GraphWeaver.root, helper))
|
|
136
|
+
return if body.match?(REQUIRED_ALREADY) # a re-run, or done by hand
|
|
137
|
+
|
|
138
|
+
# after rspec-rails' own require where there is one, at the end
|
|
139
|
+
# otherwise — either way top-level in a file every spec loads
|
|
140
|
+
if (anchor = body[RSPEC_RAILS_REQUIRE])
|
|
141
|
+
insert_into_file helper, "#{RSPEC_REQUIRE}\n", after: anchor
|
|
142
|
+
else
|
|
143
|
+
append_to_file helper, "\n#{RSPEC_REQUIRE}\n"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
72
147
|
# A url is introspected and a schema class dumped; a dump the app
|
|
73
148
|
# already has is left where it is (schema_path points at it instead).
|
|
74
149
|
def fetch_schema
|
|
75
150
|
return unless options[:schema] && form != :path
|
|
151
|
+
return if keep_existing_dump
|
|
76
152
|
|
|
77
153
|
if form == :url
|
|
78
154
|
# pass the var name, not just the token — it lands in the dump's
|
|
@@ -114,6 +190,33 @@ module GraphWeaver
|
|
|
114
190
|
|
|
115
191
|
private
|
|
116
192
|
|
|
193
|
+
RUBOCOP_CONFIG = ".rubocop.yml"
|
|
194
|
+
|
|
195
|
+
# rails_helper first: rspec-rails writes both, and only rails_helper
|
|
196
|
+
# has Rails booted by the time the require runs.
|
|
197
|
+
RSPEC_HELPERS = ["spec/rails_helper.rb", "spec/spec_helper.rb"].freeze
|
|
198
|
+
RSPEC_REQUIRE = 'require "graph_weaver/rspec"'
|
|
199
|
+
# the newline is part of the anchor: Thor inserts directly after the
|
|
200
|
+
# match, so without it the require lands on the end of that line
|
|
201
|
+
RSPEC_RAILS_REQUIRE = %r{^require ["']rspec/rails["'].*\n}
|
|
202
|
+
REQUIRED_ALREADY = %r{^\s*require ["']graph_weaver/rspec["']}
|
|
203
|
+
|
|
204
|
+
def rubocop_config = File.join(GraphWeaver.root, RUBOCOP_CONFIG)
|
|
205
|
+
|
|
206
|
+
# Parsed, not counted: a `---` can also be a line inside a block scalar.
|
|
207
|
+
# A file rubocop itself can't read is left to rubocop to complain about.
|
|
208
|
+
def yaml_documents(body)
|
|
209
|
+
YAML.parse_stream(body).children.size
|
|
210
|
+
rescue Psych::SyntaxError
|
|
211
|
+
1
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Every graph's output directory, so a multi-schema app is covered by
|
|
215
|
+
# the same run — read off the graphs rather than restated here.
|
|
216
|
+
def generated_globs
|
|
217
|
+
GraphWeaver.graphs.map { |graph| File.join(graph.output, "**/*") }.uniq
|
|
218
|
+
end
|
|
219
|
+
|
|
117
220
|
# This install run is the one moment the user is guaranteed to be
|
|
118
221
|
# reading, and a composed supergraph changes what the next steps are:
|
|
119
222
|
# the test client is the interesting one, and there's a CI gate to add.
|
|
@@ -141,9 +244,13 @@ module GraphWeaver
|
|
|
141
244
|
|
|
142
245
|
@subgraphs =
|
|
143
246
|
begin
|
|
144
|
-
|
|
247
|
+
# asked, not rescued: the refusal writes a warn line as it is
|
|
248
|
+
# built, and "this isn't a supergraph" is the ordinary answer
|
|
249
|
+
if form == :path && GraphWeaver::SchemaLoader.routing_table?(source)
|
|
250
|
+
GraphWeaver::SchemaLoader.routing_table(source).subgraphs
|
|
251
|
+
end
|
|
145
252
|
rescue StandardError
|
|
146
|
-
# not
|
|
253
|
+
# not readable — nothing to say either way
|
|
147
254
|
nil
|
|
148
255
|
end
|
|
149
256
|
end
|
|
@@ -186,6 +293,33 @@ module GraphWeaver
|
|
|
186
293
|
|
|
187
294
|
def auth_var = options[:auth] || GraphWeaver::SchemaLoader::DEFAULT_AUTH_ENV
|
|
188
295
|
|
|
296
|
+
# The dump is the one file the generator doesn't write through
|
|
297
|
+
# create_file, so Thor can't prompt on it — declining every conflict on
|
|
298
|
+
# a re-run still replaced it, and with it the source url it records.
|
|
299
|
+
# It is never overwritten here: `schema:refresh` is the command for
|
|
300
|
+
# that, and it re-fetches in place without touching anything else.
|
|
301
|
+
# True when there is one, having said so.
|
|
302
|
+
def keep_existing_dump
|
|
303
|
+
path = GraphWeaver::SchemaLoader.locate_path or return false
|
|
304
|
+
|
|
305
|
+
recorded = GraphWeaver::SchemaLoader.provenance(path)&.dig("url")
|
|
306
|
+
# a re-run naming a different endpoint would otherwise be answered
|
|
307
|
+
# silently by the dump the old one left
|
|
308
|
+
from = " (introspected from #{recorded})" if recorded && recorded != source
|
|
309
|
+
say_status :keep, "#{GraphWeaver::Internal::Util.relative(path)}#{from} — " \
|
|
310
|
+
"delete it and re-run to re-introspect", :yellow
|
|
311
|
+
true
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# --auth is what says this API takes a token. Without it the line is
|
|
315
|
+
# shown rather than wired: a public API's initializer shouldn't read an
|
|
316
|
+
# ENV var nobody set, and the commented line is how you add one later.
|
|
317
|
+
def auth_setting
|
|
318
|
+
return %(auth: ENV["#{auth_var}"],) if options[:auth]
|
|
319
|
+
|
|
320
|
+
%(# auth: ENV["#{auth_var}"], # uncomment when the API needs a token)
|
|
321
|
+
end
|
|
322
|
+
|
|
189
323
|
# The command just typed, retyped. One rule for every source form, and
|
|
190
324
|
# the only one that always works: the files already written come back
|
|
191
325
|
# "identical", and --auth rides along — where schema:refresh has no flag
|
|
@@ -202,7 +336,7 @@ module GraphWeaver
|
|
|
202
336
|
# Custom scalars, enums and type mixins go here — `rake graph_weaver:generate`
|
|
203
337
|
# bakes them into the generated source, so they must be registered first:
|
|
204
338
|
#
|
|
205
|
-
# GraphWeaver.register_scalar("
|
|
339
|
+
# GraphWeaver.register_scalar("Money", BigDecimal)
|
|
206
340
|
# GraphWeaver.extend_type("Person", Greetable)
|
|
207
341
|
RUBY
|
|
208
342
|
end
|
|
@@ -215,7 +349,7 @@ module GraphWeaver
|
|
|
215
349
|
<<~RUBY
|
|
216
350
|
GraphWeaver.client = GraphWeaver.new(
|
|
217
351
|
"#{source}",
|
|
218
|
-
|
|
352
|
+
#{auth_setting}
|
|
219
353
|
cache: true, # reuse the committed dump; delete it to re-introspect
|
|
220
354
|
)
|
|
221
355
|
RUBY
|
data/lib/graph_weaver/client.rb
CHANGED
|
@@ -45,7 +45,7 @@ class GraphWeaver::Client
|
|
|
45
45
|
private_constant :CONTEXT_IN_PROCESS, :RETRY_RULE
|
|
46
46
|
|
|
47
47
|
def initialize(source, auth: nil, headers: {}, transport: nil, cache: nil, ttl: nil,
|
|
48
|
-
open_timeout: nil, read_timeout: nil, context: nil,
|
|
48
|
+
open_timeout: nil, read_timeout: nil, pool_size: nil, context: nil,
|
|
49
49
|
retries: false, backoff: nil, base_delay: nil, max_delay: nil, jitter: nil, retry_on: nil,
|
|
50
50
|
retry_if: nil, retry_codes: nil, retry_mutations: nil, sleeper: nil, &middleware)
|
|
51
51
|
check_source!(source)
|
|
@@ -58,10 +58,11 @@ class GraphWeaver::Client
|
|
|
58
58
|
if source.is_a?(String) && source.match?(URL)
|
|
59
59
|
raise ArgumentError, CONTEXT_IN_PROCESS if context
|
|
60
60
|
|
|
61
|
-
built = build_transport(source, auth:, headers:, kind: transport, open_timeout:, read_timeout:,
|
|
61
|
+
built = build_transport(source, auth:, headers:, kind: transport, open_timeout:, read_timeout:, pool_size:,
|
|
62
|
+
&middleware)
|
|
62
63
|
@transport = wrap_retries(built, retries, retry_options)
|
|
63
64
|
else
|
|
64
|
-
if auth || middleware || retries || open_timeout || read_timeout || !retry_options.empty?
|
|
65
|
+
if auth || middleware || retries || open_timeout || read_timeout || pool_size || !retry_options.empty?
|
|
65
66
|
raise ArgumentError, "auth:/retries:/timeouts/middleware apply to a url — got a schema source"
|
|
66
67
|
end
|
|
67
68
|
if transport.is_a?(Symbol)
|
|
@@ -94,6 +95,27 @@ class GraphWeaver::Client
|
|
|
94
95
|
@schema_lock = Mutex.new
|
|
95
96
|
end
|
|
96
97
|
|
|
98
|
+
# Called by generated code — not semver'd for direct use.
|
|
99
|
+
#
|
|
100
|
+
# What actually runs a request, for anything in a client slot. A bare
|
|
101
|
+
# graphql-ruby schema class satisfies the execute contract on its own, so
|
|
102
|
+
# `client "Billing::Schema"`, `GraphWeaver.client = MyApp::Schema` and
|
|
103
|
+
# `execute!(client: MyApp::Schema)` all worked — and every one of them ran
|
|
104
|
+
# with no instrumentation seam at all: Schema.execute is not ours to
|
|
105
|
+
# bracket, so there was no APM event and no log line, not even at debug.
|
|
106
|
+
# One rule, applied wherever a client is read: a schema class gets the same
|
|
107
|
+
# InProcess wrapper GraphWeaver.new(Schema) builds. Everything else — a
|
|
108
|
+
# Client, a transport, a Retry, a fake — passes through untouched.
|
|
109
|
+
#
|
|
110
|
+
# Not memoized: the wrapper is two ivars beside a whole GraphQL execution,
|
|
111
|
+
# and in dev the class object is replaced on reload, so anything held onto
|
|
112
|
+
# would be the stale one.
|
|
113
|
+
def self.instrumented(client)
|
|
114
|
+
return client unless client.is_a?(Class) && client <= GraphQL::Schema
|
|
115
|
+
|
|
116
|
+
GraphWeaver::InProcess.new(client)
|
|
117
|
+
end
|
|
118
|
+
|
|
97
119
|
# The transport queries run through: a url-built transport, an
|
|
98
120
|
# explicit transport:, or the live schema class executing in-process.
|
|
99
121
|
# Clients are self-contained — the app default never leaks in; nil for
|
|
@@ -165,15 +187,18 @@ class GraphWeaver::Client
|
|
|
165
187
|
# it lets an unrelated gem swap your transport — along with its
|
|
166
188
|
# timeouts and, since Faraday's default net_http adapter reconnects
|
|
167
189
|
# per request, your connection reuse. Same code, same transport.
|
|
168
|
-
def build_transport(url, auth:, headers:, kind:, open_timeout: nil, read_timeout: nil, &middleware)
|
|
190
|
+
def build_transport(url, auth:, headers:, kind:, open_timeout: nil, read_timeout: nil, pool_size: nil, &middleware)
|
|
169
191
|
headers = headers.dup
|
|
170
192
|
if auth
|
|
171
|
-
unless auth.is_a?(String)
|
|
172
|
-
raise ArgumentError, "auth: takes a token string,
|
|
173
|
-
"
|
|
193
|
+
unless auth.is_a?(String) || auth.respond_to?(:call)
|
|
194
|
+
raise ArgumentError, "auth: takes a token string, or something answering #call that returns " \
|
|
195
|
+
"one per request, got #{auth.class} — other headers go in headers:"
|
|
174
196
|
end
|
|
175
197
|
|
|
176
|
-
|
|
198
|
+
# a callable stays callable: both transports resolve a header value per
|
|
199
|
+
# request, which is what a token that expires needs
|
|
200
|
+
headers["Authorization"] ||=
|
|
201
|
+
auth.respond_to?(:call) ? -> { bearer(auth.call) } : bearer(auth)
|
|
177
202
|
end
|
|
178
203
|
|
|
179
204
|
# nil means "the transport's default" — both bundled ones agree on it
|
|
@@ -181,15 +206,27 @@ class GraphWeaver::Client
|
|
|
181
206
|
|
|
182
207
|
transport =
|
|
183
208
|
if transport_kind(kind, middleware) == :faraday
|
|
209
|
+
# Faraday's adapter owns its connections; a pool ceiling here would be
|
|
210
|
+
# a number nothing reads, so say so instead of dropping it
|
|
211
|
+
raise ArgumentError, "pool_size: sizes the bundled HTTP transport's pool — Faraday's adapter " \
|
|
212
|
+
"manages its own connections, so configure it there" if pool_size
|
|
184
213
|
build_faraday(url, headers:, timeouts:, &middleware)
|
|
185
214
|
else
|
|
186
|
-
GraphWeaver::Transport::HTTP.new(url, headers:, **timeouts)
|
|
215
|
+
GraphWeaver::Transport::HTTP.new(url, headers:, pool_size:, **timeouts)
|
|
187
216
|
end
|
|
188
217
|
|
|
189
|
-
GraphWeaver::Internal::Log.log(:info) { "transport: #{transport.class} -> #{
|
|
218
|
+
GraphWeaver::Internal::Log.log(:info) { "transport: #{transport.class} -> #{transport.safe_url}" }
|
|
190
219
|
transport
|
|
191
220
|
end
|
|
192
221
|
|
|
222
|
+
# "Bearer" is assumed unless the token carries its own scheme; nil is a
|
|
223
|
+
# token the caller declined to produce, and drops the header.
|
|
224
|
+
def bearer(token)
|
|
225
|
+
return if token.nil?
|
|
226
|
+
|
|
227
|
+
token.to_s.include?(" ") ? token.to_s : "Bearer #{token}"
|
|
228
|
+
end
|
|
229
|
+
|
|
193
230
|
# Which bundled transport a url client builds: the explicit
|
|
194
231
|
# transport:, else Faraday when a middleware block asks for it.
|
|
195
232
|
def transport_kind(kind, middleware)
|
|
@@ -62,7 +62,7 @@ class GraphWeaver::Codegen
|
|
|
62
62
|
# than the file.
|
|
63
63
|
def check_alias_name!(node, name)
|
|
64
64
|
taken = node.fields.any? { |f| f.prop == name } ||
|
|
65
|
-
|
|
65
|
+
RESERVED_PROPS.include?(name) || ALIAS_RESERVED.include?(name) ||
|
|
66
66
|
RUBY_KEYWORDS.include?(name)
|
|
67
67
|
return unless taken
|
|
68
68
|
|
|
@@ -72,10 +72,10 @@ class GraphWeaver::Codegen
|
|
|
72
72
|
|
|
73
73
|
# Registered aliases for a GraphQL type (see extend_type alias:).
|
|
74
74
|
def type_aliases(graphql_name)
|
|
75
|
-
|
|
75
|
+
@registry.type_registry[graphql_name]&.dig(:aliases) || {}
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
-
# The CLASS methods a generated struct defines;
|
|
78
|
+
# The CLASS methods a generated struct defines; RESERVED_PROPS covers the
|
|
79
79
|
# instance side, and both are checked with RUBY_KEYWORDS alongside (all
|
|
80
80
|
# three are defined by the class this mixes into).
|
|
81
81
|
ALIAS_RESERVED = %w[from_h].to_set.freeze
|
|
@@ -163,10 +163,12 @@ class GraphWeaver::Codegen
|
|
|
163
163
|
type = obj.graphql_type && @schema.get_type(obj.graphql_type)
|
|
164
164
|
return unless type.respond_to?(:fields)
|
|
165
165
|
|
|
166
|
-
|
|
166
|
+
# prop_name, not underscore: a path hops through PROPS, so a reserved
|
|
167
|
+
# field is spelled with its trailing underscore here too
|
|
168
|
+
known = type.fields.keys.map { |field| GraphWeaver::Codegen.prop_name(field) }
|
|
167
169
|
return if seg == "__typename" || known.include?(seg)
|
|
168
170
|
|
|
169
|
-
prop = GraphWeaver::
|
|
171
|
+
prop = GraphWeaver::Codegen.prop_name(seg)
|
|
170
172
|
hint = if prop != seg && known.include?(prop)
|
|
171
173
|
# paths are the Ruby prop chain, not the GraphQL one — the classic miss
|
|
172
174
|
" — GraphQL fields generate snake_case props; use '#{prop}'"
|
|
@@ -111,6 +111,15 @@ class GraphWeaver::Codegen
|
|
|
111
111
|
}.uniq
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
+
# `module A::B` does not define A, so a namespaced graph's file opens each
|
|
115
|
+
# outer segment first. A line apiece rather than nesting the whole body:
|
|
116
|
+
# the generated Ruby is then identical but for these, so adding a
|
|
117
|
+
# namespace: later diffs as additions.
|
|
118
|
+
def emit_namespace(out)
|
|
119
|
+
parts = @name.split("::")
|
|
120
|
+
(1...parts.size).each { |i| out << "module #{parts.first(i).join("::")}; end" }
|
|
121
|
+
end
|
|
122
|
+
|
|
114
123
|
def emit_shared_aliases(out, names, namespace)
|
|
115
124
|
return if names.empty?
|
|
116
125
|
|
|
@@ -148,6 +157,7 @@ class GraphWeaver::Codegen
|
|
|
148
157
|
requires.each { |req| out << "require #{req.inspect}" }
|
|
149
158
|
out << ""
|
|
150
159
|
end
|
|
160
|
+
emit_namespace(out)
|
|
151
161
|
out << "module #{@name}; end"
|
|
152
162
|
out << ""
|
|
153
163
|
if inputs.any?
|
|
@@ -184,6 +194,7 @@ class GraphWeaver::Codegen
|
|
|
184
194
|
out << ""
|
|
185
195
|
out << "# Generated by GraphWeaver #{GraphWeaver::VERSION} — do not edit."
|
|
186
196
|
out << ""
|
|
197
|
+
emit_namespace(out)
|
|
187
198
|
out << "module #{@name}"
|
|
188
199
|
yield(out)
|
|
189
200
|
out << "end"
|
|
@@ -225,6 +236,7 @@ class GraphWeaver::Codegen
|
|
|
225
236
|
out << "require_relative \"types\""
|
|
226
237
|
out << ""
|
|
227
238
|
end
|
|
239
|
+
emit_namespace(out)
|
|
228
240
|
out << "module #{@name}"
|
|
229
241
|
out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
|
|
230
242
|
# a GraphQL block string could contain a bare GRAPHQL line, which
|
|
@@ -233,7 +245,13 @@ class GraphWeaver::Codegen
|
|
|
233
245
|
delimiter = "GRAPHQL"
|
|
234
246
|
delimiter += "_" while @query.match?(/^\s*#{delimiter}\s*$/)
|
|
235
247
|
out << " QUERY = T.let(<<~'#{delimiter}', String)"
|
|
236
|
-
|
|
248
|
+
# only the line's own newline comes off: trailing whitespace is part of a
|
|
249
|
+
# block-string argument's value, and <<~ takes the indent back off again.
|
|
250
|
+
# An empty line has no content to indent, so it stays empty.
|
|
251
|
+
@query.each_line do |line|
|
|
252
|
+
content = line.chomp
|
|
253
|
+
out << (content.empty? ? "" : " #{content}")
|
|
254
|
+
end
|
|
237
255
|
out << " #{delimiter}"
|
|
238
256
|
out << ""
|
|
239
257
|
out << " # sent as the request's operationName — what an APM keys traces on"
|
|
@@ -345,6 +363,7 @@ class GraphWeaver::Codegen
|
|
|
345
363
|
out << "#{pad}class #{node.class_name} < T::Struct"
|
|
346
364
|
out << "#{pad} extend T::Sig" if GraphWeaver.extend_t_sig?
|
|
347
365
|
out << "#{pad} include GraphWeaver::Hints"
|
|
366
|
+
out << "#{pad} include GraphWeaver::ResultStruct"
|
|
348
367
|
node.mixins.each do |mixin|
|
|
349
368
|
out << "#{pad} include #{mixin} # registered for #{node.graphql_type}"
|
|
350
369
|
end
|
|
@@ -362,6 +381,8 @@ class GraphWeaver::Codegen
|
|
|
362
381
|
end
|
|
363
382
|
|
|
364
383
|
node.fields.each do |field|
|
|
384
|
+
note = renamed_note(field.prop, field.key)
|
|
385
|
+
out << "#{pad} #{note}" if note
|
|
365
386
|
out << "#{pad} const :#{field.prop}, #{field.node.prop_type}"
|
|
366
387
|
end
|
|
367
388
|
|
|
@@ -376,10 +397,23 @@ class GraphWeaver::Codegen
|
|
|
376
397
|
out << "#{pad} rescue GraphWeaver::Error"
|
|
377
398
|
out << "#{pad} raise # already branded by a nested struct or leaf — keep the innermost context"
|
|
378
399
|
out << "#{pad} rescue StandardError => e" # sorbet's prop check, mostly
|
|
379
|
-
out << "#{pad} raise GraphWeaver::
|
|
400
|
+
out << "#{pad} raise GraphWeaver::CastError.new(struct: self, " \
|
|
380
401
|
"message: GraphWeaver::Hints.cast_message(self, data, e))"
|
|
381
402
|
out << "#{pad} end"
|
|
382
403
|
|
|
404
|
+
# the mirror of from_h: the response keys, each leaf back through its
|
|
405
|
+
# scalar's serialize:, so from_h reads what as_json wrote. Rails calls
|
|
406
|
+
# it for `render json:`, and ResultStruct#to_json goes through it.
|
|
407
|
+
out << ""
|
|
408
|
+
out << "#{pad} sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }"
|
|
409
|
+
out << "#{pad} def as_json(*_options)"
|
|
410
|
+
out << "#{pad} {"
|
|
411
|
+
node.fields.each do |field|
|
|
412
|
+
out << "#{pad} #{field.key.inspect} => #{field_json(field)},"
|
|
413
|
+
end
|
|
414
|
+
out << "#{pad} }"
|
|
415
|
+
out << "#{pad} end"
|
|
416
|
+
|
|
383
417
|
# alias delegators (extend_type alias:) — typed accessors that project a
|
|
384
418
|
# selected field onto the struct, next to the honest wire data
|
|
385
419
|
node.aliases.each do |a|
|
|
@@ -396,7 +430,7 @@ class GraphWeaver::Codegen
|
|
|
396
430
|
out << "#{pad}module #{node.class_name}"
|
|
397
431
|
out << "#{pad} extend T::Sig" << "" if GraphWeaver.extend_t_sig?
|
|
398
432
|
|
|
399
|
-
structs = node.members.values + [node.catch_all]
|
|
433
|
+
structs = node.members.values + [node.catch_all]
|
|
400
434
|
structs.each do |member|
|
|
401
435
|
emit_object(member, out, indent + 1)
|
|
402
436
|
out << ""
|
|
@@ -408,21 +442,14 @@ class GraphWeaver::Codegen
|
|
|
408
442
|
out << ""
|
|
409
443
|
out << "#{pad} sig { params(data: T::Hash[String, T.untyped]).returns(Type) }"
|
|
410
444
|
out << "#{pad} def self.from_h(data)"
|
|
411
|
-
|
|
412
|
-
out << "#{pad} case data.fetch(\"__typename\")"
|
|
413
|
-
else
|
|
414
|
-
out << "#{pad} case (typename = data.fetch(\"__typename\"))"
|
|
415
|
-
end
|
|
445
|
+
out << "#{pad} case data.fetch(\"__typename\")"
|
|
416
446
|
node.members.each do |graphql_name, member|
|
|
417
447
|
out << "#{pad} when #{graphql_name.inspect} then #{member.class_name}.from_h(data)"
|
|
418
448
|
end
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
else
|
|
424
|
-
out << "#{pad} else raise GraphWeaver::TypeError.new(struct: self, message: \"unexpected __typename: \#{typename}\")"
|
|
425
|
-
end
|
|
449
|
+
# every dispatch has a catch-all, so no __typename is unexpected
|
|
450
|
+
out << "#{pad} # a member this query names no fields on — including one the"
|
|
451
|
+
out << "#{pad} # schema grew since generation"
|
|
452
|
+
out << "#{pad} else #{node.catch_all.class_name}.from_h(data)"
|
|
426
453
|
out << "#{pad} end"
|
|
427
454
|
out << "#{pad} end"
|
|
428
455
|
out << "#{pad}end"
|
|
@@ -432,6 +459,13 @@ class GraphWeaver::Codegen
|
|
|
432
459
|
# client/client= carry no per-query types, so they live in the gem
|
|
433
460
|
out << " # client / client= — see GraphWeaver::QueryModule"
|
|
434
461
|
out << " extend GraphWeaver::QueryModule"
|
|
462
|
+
if @graph_name
|
|
463
|
+
out << ""
|
|
464
|
+
out << " # the graph this module was generated from — what a test mode builds"
|
|
465
|
+
out << " # its stand-in client from"
|
|
466
|
+
out << " GRAPH = T.let(#{@graph_name.inspect}, Symbol)"
|
|
467
|
+
out << " private_constant :GRAPH"
|
|
468
|
+
end
|
|
435
469
|
if @client_const
|
|
436
470
|
out << ""
|
|
437
471
|
out << " # the baked default client, resolved on first use"
|
|
@@ -474,12 +508,15 @@ class GraphWeaver::Codegen
|
|
|
474
508
|
flag ? "#{var.kwarg}: (#{flag} = true; nil)" : "#{var.kwarg}: nil"
|
|
475
509
|
} + ["client: nil"]
|
|
476
510
|
|
|
477
|
-
|
|
511
|
+
# QueryModule#dispatch reads QUERY/OPERATION_NAME/GRAPH off the module,
|
|
512
|
+
# so the gem gets to bracket every request without a line of it landing
|
|
513
|
+
# in every generated file
|
|
514
|
+
call = "dispatch(variables, client:)"
|
|
478
515
|
|
|
479
516
|
# execute returns the full envelope; execute! is the strict shortcut for
|
|
480
517
|
# the typed result, or a raised QueryError.
|
|
481
|
-
out << " # .checked(:never): an untyped value (a Rails param) reaches the coercion
|
|
482
|
-
out << " # instead of sorbet-runtime's argument check
|
|
518
|
+
out << " # .checked(:never): an untyped value (a Rails param) reaches the coercion"
|
|
519
|
+
out << " # below instead of being rejected by sorbet-runtime's argument check."
|
|
483
520
|
out << " sig { params(#{sig_params.join(", ")}).returns(GraphWeaver::Response[Result]).checked(:never) }"
|
|
484
521
|
out << " def self.execute(#{kwargs.join(", ")})"
|
|
485
522
|
emit_variables(out, required, optional, omitted)
|
|
@@ -558,16 +595,16 @@ class GraphWeaver::Codegen
|
|
|
558
595
|
|
|
559
596
|
# A kwarg's trip onto the wire: normalize whatever arrived into the type
|
|
560
597
|
# the sig promises — the sig itself is `.checked(:never)`, so this is the
|
|
561
|
-
# check — then serialize.
|
|
562
|
-
# variable and the operation; the value
|
|
598
|
+
# check — then serialize. The whole trip runs inside Coerce.variable, so a
|
|
599
|
+
# refusal from either half names the variable and the operation; the value
|
|
600
|
+
# alone locates nothing. (A serializer raising used to escape bare: a cast
|
|
601
|
+
# that answers nil hands `nil.upcase` to whatever called execute.)
|
|
563
602
|
def variable_serialize(var)
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
end
|
|
570
|
-
var.node.serialize_identity? ? value : var.node.serialize(value, 1)
|
|
603
|
+
return var.kwarg if !var.node.coerce? && var.node.serialize_identity?
|
|
604
|
+
|
|
605
|
+
value = var.node.coerce? ? var.node.coerce("v") : "v"
|
|
606
|
+
value = var.node.serialize(value, 1) unless var.node.serialize_identity?
|
|
607
|
+
"GraphWeaver::Coerce.variable(#{var.wire.inspect}, OPERATION_NAME, #{var.kwarg}) { |v| #{value} }"
|
|
571
608
|
end
|
|
572
609
|
|
|
573
610
|
def field_cast(field)
|
|
@@ -588,6 +625,30 @@ class GraphWeaver::Codegen
|
|
|
588
625
|
"GraphWeaver::Hints.field(self, #{field.key.inspect}) { #{cast} }"
|
|
589
626
|
end
|
|
590
627
|
|
|
628
|
+
# One prop written back the way the server spelled it. The inverse of
|
|
629
|
+
# field_cast, minus its branding: rendering a value the library already
|
|
630
|
+
# cast can't fail on the value, only on a registration whose serialize:
|
|
631
|
+
# raises — which is the app's own code and says so.
|
|
632
|
+
def field_json(field)
|
|
633
|
+
node = field.node
|
|
634
|
+
# a prop is only ever read off a receiver, which is what lets `next` and
|
|
635
|
+
# `end` be props at all (see RUBY_KEYWORDS) — so here the receiver is us
|
|
636
|
+
prop = RUBY_KEYWORDS.include?(field.prop) ? "self.#{field.prop}" : field.prop
|
|
637
|
+
return prop if node.serialize_identity?
|
|
638
|
+
return node.serialize(prop, 1) if node.non_null?
|
|
639
|
+
|
|
640
|
+
"#{prop}&.then { |v1| #{node.serialize("v1", 2)} }"
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# Why a prop is spelled unlike its wire name, for the one case a reader
|
|
644
|
+
# can't infer: a reserved name took a trailing underscore. camelCase →
|
|
645
|
+
# snake_case is the rule the whole file follows, and the wire key is
|
|
646
|
+
# already spelled out beside it (from_h's `data[...]`, a FIELDS row), so
|
|
647
|
+
# noting every prop would be noise.
|
|
648
|
+
def renamed_note(prop, wire)
|
|
649
|
+
"# wire: #{wire} — reserved as a prop name" if prop == "#{underscore(wire)}_"
|
|
650
|
+
end
|
|
651
|
+
|
|
591
652
|
# A module-level T::Struct per input type: typed consts plus a FIELDS
|
|
592
653
|
# table the GraphWeaver::InputStruct runtime drives — serialize/to_h/
|
|
593
654
|
# coerce live once in the gem, not unrolled per struct (bool_exp
|
|
@@ -599,6 +660,11 @@ class GraphWeaver::Codegen
|
|
|
599
660
|
out << "#{pad} include GraphWeaver::InputStruct"
|
|
600
661
|
out << "#{pad} extend GraphWeaver::InputStruct::ClassMethods"
|
|
601
662
|
out << ""
|
|
663
|
+
# the schema's name for this type, so a refusal reports GraphQL
|
|
664
|
+
# vocabulary rather than the Ruby class generation happened to pick
|
|
665
|
+
out << "#{pad} GRAPHQL_NAME = T.let(#{node.graphql_name.inspect}, String)"
|
|
666
|
+
out << "#{pad} private_constant :GRAPHQL_NAME"
|
|
667
|
+
out << ""
|
|
602
668
|
if node.one_of
|
|
603
669
|
out << "#{pad} # @oneOf: every field is nullable, so exactly-one is checked at runtime"
|
|
604
670
|
out << "#{pad} ONE_OF = T.let(true, T::Boolean)"
|
|
@@ -612,15 +678,18 @@ class GraphWeaver::Codegen
|
|
|
612
678
|
type = field.node.prop_type
|
|
613
679
|
type = "T.nilable(#{type})" if !field.required && field.node.non_null? && type != "T.untyped"
|
|
614
680
|
default = field.required ? "" : ", default: nil"
|
|
681
|
+
note = renamed_note(field.prop, field.wire)
|
|
682
|
+
out << "#{pad} #{note}" if note
|
|
615
683
|
out << "#{pad} const :#{field.prop}, #{type}#{default}"
|
|
616
684
|
end
|
|
617
685
|
out << ""
|
|
618
|
-
out << "#{pad} # (prop, wire, required, serializer, coercer) per field"
|
|
686
|
+
out << "#{pad} # (prop, wire, required, serializer, coercer, coordinate) per field"
|
|
619
687
|
out << "#{pad} FIELDS = T.let(["
|
|
620
688
|
node.fields.each do |field|
|
|
621
689
|
serializer = field.node.serialize_identity? ? "nil" : "->(v) { #{field.node.serialize("v", 1)} }"
|
|
622
690
|
coercer = field.node.hash_coerce_identity? ? "nil" : "->(v) { #{field.node.hash_coerce("v", 1)} }"
|
|
623
|
-
|
|
691
|
+
coordinate = "#{node.graphql_name}.#{field.wire}"
|
|
692
|
+
out << "#{pad} GraphWeaver::InputStruct::Field.new(:#{field.prop}, #{field.wire.inspect}, #{field.required}, #{serializer}, #{coercer}, #{coordinate.inspect}),"
|
|
624
693
|
end
|
|
625
694
|
out << "#{pad} ].freeze, T::Array[GraphWeaver::InputStruct::Field])"
|
|
626
695
|
# InputStruct reads it with const_get, which privacy doesn't block
|
|
@@ -78,7 +78,8 @@ class GraphWeaver::Codegen
|
|
|
78
78
|
end
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
# The enum half of one graph's registrations — see Codegen::Registry.
|
|
82
|
+
class Registry
|
|
82
83
|
# Map a GraphQL enum onto an app-owned T::Enum (see EnumType). The one
|
|
83
84
|
# implementation — GraphWeaver.register_enum is a delegate, so the same
|
|
84
85
|
# call reaches it whichever door you came in by.
|