graph_weaver 0.6.1 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile +8 -0
- data/Gemfile.lock +153 -4
- data/README.md +45 -79
- data/docs/alternatives.md +195 -0
- data/docs/cassettes.md +61 -50
- data/docs/editors.md +32 -47
- data/docs/errors.md +360 -103
- data/docs/federation.md +692 -473
- data/docs/generated_modules.md +441 -314
- data/docs/getting_started.md +370 -194
- data/docs/i18n.md +171 -0
- data/docs/logging.md +197 -50
- data/docs/real_world.md +42 -27
- data/docs/scalars.md +307 -176
- data/docs/testing.md +473 -220
- data/docs/transports.md +224 -151
- data/docs/upgrading.md +258 -305
- data/examples/README.md +38 -0
- data/examples/countries.rb +39 -0
- data/examples/federation.rb +62 -0
- data/examples/github/generate.rb +20 -0
- data/examples/github/generated/star_mutation.rb +126 -0
- data/examples/github/generated/stargazers_query.rb +232 -0
- data/examples/github/generated/starred_query.rb +151 -0
- data/examples/github/queries/star.graphql +8 -0
- data/examples/github/queries/stargazers.graphql +22 -0
- data/examples/github/queries/starred.graphql +11 -0
- data/examples/github/run.rb +43 -0
- data/examples/github/setup.rb +18 -0
- data/examples/rick_and_morty.rb +57 -0
- data/graph_weaver.gemspec +19 -3
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +69 -11
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +406 -195
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +284 -46
- data/lib/graph_weaver/federation.rb +129 -27
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +27 -15
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +80 -0
- data/lib/graph_weaver/internal/headers.rb +70 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +40 -4
- data/lib/graph_weaver/internal.rb +249 -14
- data/lib/graph_weaver/log_subscriber.rb +74 -0
- data/lib/graph_weaver/logging.rb +163 -19
- data/lib/graph_weaver/query_module.rb +44 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +45 -13
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +266 -56
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +34 -10
- data/lib/graph_weaver/testing/endpoint.rb +107 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +164 -45
- data/lib/graph_weaver/testing/router.rb +64 -13
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +48 -6
- data/lib/graph_weaver/transport.rb +134 -27
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +495 -106
- metadata +71 -3
- data/CHANGELOG.md +0 -2355
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "graphql"
|
|
6
|
+
require "json"
|
|
6
7
|
require "yaml"
|
|
7
8
|
|
|
8
9
|
module GraphWeaver
|
|
@@ -19,7 +20,7 @@ module GraphWeaver
|
|
|
19
20
|
def initialize(path:, query:, variables:, recorded:, size:)
|
|
20
21
|
super([
|
|
21
22
|
"no recording for this request in #{GraphWeaver::Internal::Util.relative(path)}",
|
|
22
|
-
" variables: #{GraphWeaver::Internal::Log.filter_variables(Internal::RequestKey.normalize_variables(variables))
|
|
23
|
+
" variables: #{JSON.generate(GraphWeaver::Internal::Log.filter_variables(Internal::RequestKey.normalize_variables(variables)))}",
|
|
23
24
|
" #{self.class.recorded_summary(recorded, size)}",
|
|
24
25
|
" query: #{Internal::RequestKey.summarize(query)}",
|
|
25
26
|
"re-record it (GRAPHWEAVER_RECORD=1 with a client:), or delete the cassette to start over.",
|
|
@@ -30,7 +31,7 @@ module GraphWeaver
|
|
|
30
31
|
return "no entry recorded for this query (#{size} in the cassette)" if recorded.empty?
|
|
31
32
|
|
|
32
33
|
more = recorded.size > SHOWN ? " (+#{recorded.size - SHOWN} more)" : ""
|
|
33
|
-
shown = recorded.first(SHOWN).map { |set| GraphWeaver::Internal::Log.filter_variables(set)
|
|
34
|
+
shown = recorded.first(SHOWN).map { |set| JSON.generate(GraphWeaver::Internal::Log.filter_variables(set)) }
|
|
34
35
|
"#{recorded.size} #{(recorded.size == 1) ? "entry" : "entries"} recorded for this query, " \
|
|
35
36
|
"with variables #{shown.join(", ")}#{more}"
|
|
36
37
|
end
|
|
@@ -85,7 +86,7 @@ module GraphWeaver
|
|
|
85
86
|
|
|
86
87
|
["#{GraphWeaver::Internal::Util.relative(path)}: #{stale.size} stale (#{counted.join(", ")})"] +
|
|
87
88
|
stale.flat_map do |entry|
|
|
88
|
-
[" #{entry.module_name} #{entry.variables
|
|
89
|
+
[" #{entry.module_name} #{JSON.generate(entry.variables)}", " #{entry.message}"]
|
|
89
90
|
end
|
|
90
91
|
end
|
|
91
92
|
end
|
|
@@ -114,11 +115,8 @@ module GraphWeaver
|
|
|
114
115
|
|
|
115
116
|
def initialize(path)
|
|
116
117
|
@path = Testing.cassette_path(path)
|
|
117
|
-
@entries =
|
|
118
|
+
@entries = read_entries
|
|
118
119
|
@flagged = []
|
|
119
|
-
# record is read-modify-write; two threads recording through one
|
|
120
|
-
# cassette (a parallel spec run) would each save a snapshot missing
|
|
121
|
-
# the other's entry — atomic_write keeps the file whole, not complete
|
|
122
120
|
@lock = Mutex.new
|
|
123
121
|
end
|
|
124
122
|
|
|
@@ -146,7 +144,13 @@ module GraphWeaver
|
|
|
146
144
|
entry["response"] = response
|
|
147
145
|
|
|
148
146
|
wanted = Internal::RequestKey.for(query, variables, operation_name)
|
|
149
|
-
|
|
147
|
+
# Recording rewrites the whole file, and parallel_tests points several
|
|
148
|
+
# processes at one cassette — so the read-modify-write happens under a
|
|
149
|
+
# lock every recorder shares, re-reading inside it. The snapshot taken
|
|
150
|
+
# at construction is already missing whatever another process recorded
|
|
151
|
+
# since, and saving it would throw those entries away.
|
|
152
|
+
locked do
|
|
153
|
+
@entries = read_entries
|
|
150
154
|
@entries.reject! { |existing| Internal::RequestKey.for_entry(existing) == wanted }
|
|
151
155
|
@entries << entry
|
|
152
156
|
save
|
|
@@ -160,7 +164,7 @@ module GraphWeaver
|
|
|
160
164
|
# and nothing else notices when that server's answers drift out of the
|
|
161
165
|
# shape the structs were generated for: `verify`, `queries:check` and
|
|
162
166
|
# `schema:diff` all ask about the local side. Without this the drift
|
|
163
|
-
# surfaces mid-spec as a `
|
|
167
|
+
# surfaces mid-spec as a `CastError` naming a struct and a sorbet
|
|
164
168
|
# frame, with nothing pointing at the stale file.
|
|
165
169
|
#
|
|
166
170
|
# Matching is on the query text, which is the module that sent it — a
|
|
@@ -197,6 +201,26 @@ module GraphWeaver
|
|
|
197
201
|
|
|
198
202
|
private
|
|
199
203
|
|
|
204
|
+
def read_entries = File.exist?(@path) ? YAML.safe_load_file(@path, aliases: true) : []
|
|
205
|
+
|
|
206
|
+
# Serialize a read-modify-write against every other recorder, in this
|
|
207
|
+
# process and any other. The Mutex is the threads; the flock is the
|
|
208
|
+
# processes. Both, because flock is held per open file, so one process's
|
|
209
|
+
# two threads would each take their own.
|
|
210
|
+
def locked
|
|
211
|
+
@lock.synchronize do
|
|
212
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
213
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
|
|
214
|
+
lock.flock(File::LOCK_EX)
|
|
215
|
+
yield
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# A sidecar, not the cassette itself: save renames a fresh file into
|
|
221
|
+
# place, so a lock held on the replaced inode guards nothing.
|
|
222
|
+
def lock_path = "#{@path}.lock"
|
|
223
|
+
|
|
200
224
|
def save
|
|
201
225
|
yaml = YAML.dump(@entries)
|
|
202
226
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
@@ -281,7 +305,7 @@ module GraphWeaver
|
|
|
281
305
|
|
|
282
306
|
def initialize(schema:, seed: nil, values: nil)
|
|
283
307
|
@schema = schema
|
|
284
|
-
@values = Internal::Values.new(seed:, values:)
|
|
308
|
+
@values = Internal::Values.new(seed:, values:, schema:)
|
|
285
309
|
end
|
|
286
310
|
|
|
287
311
|
# The whole response, not just `data`: an error message routinely
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module GraphWeaver
|
|
7
|
+
module Testing
|
|
8
|
+
# A Rack app serving any client — the {Router}, a live schema class, a
|
|
9
|
+
# {FakeClient} — at a GraphQL endpoint, so a query crosses a real wire:
|
|
10
|
+
# serialized by your transport, posted, deserialized by `from_h`.
|
|
11
|
+
#
|
|
12
|
+
# run GraphWeaver::Testing::Endpoint.new(router)
|
|
13
|
+
#
|
|
14
|
+
# In rspec that is the `graphql: :wire` tag, which mounts this behind
|
|
15
|
+
# your own transport's url (see graph_weaver/rspec). Anywhere else it is
|
|
16
|
+
# an ordinary Rack app — a rackup file, a Puma in a thread, WebMock's
|
|
17
|
+
# `to_rack`.
|
|
18
|
+
#
|
|
19
|
+
# It answers the way a router and graphql-ruby answer: a query the
|
|
20
|
+
# server can't parse or validate is a **200 carrying GraphQL errors**,
|
|
21
|
+
# not an HTTP failure. Only a request that isn't a GraphQL request at
|
|
22
|
+
# all — the wrong method, a body that isn't JSON — is a 400, and it says
|
|
23
|
+
# what it got.
|
|
24
|
+
#
|
|
25
|
+
# A client whose `context` is a **proc** is asked what this request's
|
|
26
|
+
# headers mean, per request: that is the identity-propagation seam, the
|
|
27
|
+
# one thing an in-process client can't test.
|
|
28
|
+
#
|
|
29
|
+
# Router.new(supergraph:, context: ->(headers) { { current_user: User.find_by(token: headers["Authorization"]) } })
|
|
30
|
+
#
|
|
31
|
+
# Answering that proc means writing the client's context for the length
|
|
32
|
+
# of one dispatch, so the client is what resolves it: any client
|
|
33
|
+
# answering `with_request_context(headers) { }` gets the seam, which
|
|
34
|
+
# {InProcess} and {Router} take from {GraphWeaver::ContextSeam}. A client
|
|
35
|
+
# without it is served untouched, and concurrently.
|
|
36
|
+
class Endpoint
|
|
37
|
+
JSON_HEADERS = { "content-type" => "application/json" }.freeze
|
|
38
|
+
TEXT_HEADERS = { "content-type" => "text/plain" }.freeze
|
|
39
|
+
private_constant :JSON_HEADERS, :TEXT_HEADERS
|
|
40
|
+
|
|
41
|
+
# how much of an unservable body the 400 quotes back
|
|
42
|
+
EXCERPT = 200
|
|
43
|
+
private_constant :EXCERPT
|
|
44
|
+
|
|
45
|
+
def initialize(client)
|
|
46
|
+
@client = client
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def call(env)
|
|
50
|
+
method = env["REQUEST_METHOD"]
|
|
51
|
+
return refuse("expected a POST of a GraphQL request, got #{method}") unless method == "POST"
|
|
52
|
+
|
|
53
|
+
body = env["rack.input"]&.read.to_s
|
|
54
|
+
request = begin
|
|
55
|
+
JSON.parse(body)
|
|
56
|
+
rescue JSON::ParserError => e
|
|
57
|
+
return refuse("expected a JSON GraphQL request body, got #{excerpt(body)} (#{e.message})")
|
|
58
|
+
end
|
|
59
|
+
unless request.is_a?(Hash) && request["query"].is_a?(String)
|
|
60
|
+
return refuse("expected a JSON GraphQL request body with a \"query\" string, got #{excerpt(body)}")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
result = with_context(headers(env)) do
|
|
64
|
+
@client.execute(request["query"], variables: request["variables"] || {},
|
|
65
|
+
operation_name: request["operationName"])
|
|
66
|
+
end
|
|
67
|
+
[200, JSON_HEADERS, [JSON.generate(result)]]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# never leak the client's context (tokens, current_user)
|
|
71
|
+
def inspect = "#<#{self.class.name} client=#{@client.class}>"
|
|
72
|
+
alias to_s inspect
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# A `context:` proc is answered from the request in hand, and the
|
|
77
|
+
# client's context is what gets written to answer it — so the client
|
|
78
|
+
# does it, under its own lock. This endpoint is built per request by
|
|
79
|
+
# `graphql: :wire`; a lock held here would guard nothing the next
|
|
80
|
+
# request shares.
|
|
81
|
+
def with_context(headers, &block)
|
|
82
|
+
return yield unless @client.respond_to?(:with_request_context)
|
|
83
|
+
|
|
84
|
+
@client.with_request_context(headers, &block)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Rack spells a header HTTP_X_CALLER; the proc reads "X-Caller".
|
|
88
|
+
# Capitalization is reconstructed, not remembered — the CGI env
|
|
89
|
+
# dropped it — so a header sent as X-CALLER arrives here as X-Caller.
|
|
90
|
+
def headers(env)
|
|
91
|
+
env.each_with_object({}) do |(key, value), headers|
|
|
92
|
+
name = case key
|
|
93
|
+
when /\AHTTP_(.+)\z/ then Regexp.last_match(1)
|
|
94
|
+
when "CONTENT_TYPE", "CONTENT_LENGTH" then key
|
|
95
|
+
end
|
|
96
|
+
next unless name && value.is_a?(String)
|
|
97
|
+
|
|
98
|
+
headers[name.downcase.split("_").map(&:capitalize).join("-")] = value
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def excerpt(body) = body.empty? ? "an empty body" : body[0, EXCERPT].inspect
|
|
103
|
+
|
|
104
|
+
def refuse(message) = [400, TEXT_HEADERS, [message]]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "json"
|
|
5
|
+
require "net/http" # Net::ReadTimeout, the shape a real read timeout arrives in
|
|
5
6
|
|
|
6
7
|
module GraphWeaver
|
|
7
8
|
module Testing
|
|
@@ -10,13 +11,14 @@ module GraphWeaver
|
|
|
10
11
|
# server that misbehaves on cue:
|
|
11
12
|
#
|
|
12
13
|
# PersonQuery.execute(client: Failure.transport, id: "1") # TransportError
|
|
14
|
+
# PersonQuery.execute(client: Failure.timeout, id: "1") # TransportError, read timeout
|
|
13
15
|
# PersonQuery.execute(client: Failure.server(status: 502), id: "1")
|
|
14
16
|
# PersonQuery.execute(client: Failure.throttled, id: "1") # QueryError, code THROTTLED
|
|
15
17
|
# PersonQuery.execute(client: Failure.stale_schema, id: "1") # schema_stale? => true
|
|
16
18
|
#
|
|
17
19
|
# For type mismatches, corrupt the wire with a FakeClient override:
|
|
18
20
|
# FakeClient.new(schema:, overrides: { "Person.birthday" => 123 })
|
|
19
|
-
# casting then raises GraphWeaver::
|
|
21
|
+
# casting then raises GraphWeaver::CastError, exactly as a bad server
|
|
20
22
|
# payload would. For partial failures, see FakeClient's fail_at:.
|
|
21
23
|
module Failure
|
|
22
24
|
include Kernel # for sorbet
|
|
@@ -25,12 +27,31 @@ module GraphWeaver
|
|
|
25
27
|
# the request never reaches the server — cause preserved, and the
|
|
26
28
|
# message shaped as the bundled transports shape it
|
|
27
29
|
def transport(message = "simulated network failure", cause: SocketError)
|
|
30
|
+
network_failure(cause, message)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The request went out and no answer came back in time — net/http's own
|
|
34
|
+
# Net::ReadTimeout as #cause, so a spec says "it timed out" without
|
|
35
|
+
# naming net/http's classes. Retriable, but a read timeout says nothing
|
|
36
|
+
# about whether the server applied the request, which is why Retry gives
|
|
37
|
+
# a mutation one attempt.
|
|
38
|
+
def timeout(message = "simulated read timeout")
|
|
39
|
+
network_failure(Net::ReadTimeout, message)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# What Transport does with a network-level failure: a TransportError
|
|
43
|
+
# reading "Class: detail", the original preserved as #cause. The detail
|
|
44
|
+
# is passed rather than read off the exception — Net::ReadTimeout's own
|
|
45
|
+
# initialize takes the socket it gave up on, not a message, so a string
|
|
46
|
+
# handed to `raise` lands in quotes where the socket goes.
|
|
47
|
+
def network_failure(cause, message)
|
|
28
48
|
FailureClient.new do
|
|
29
|
-
raise cause
|
|
49
|
+
raise cause
|
|
30
50
|
rescue cause => e
|
|
31
|
-
raise GraphWeaver::TransportError, "#{e.class}: #{
|
|
51
|
+
raise GraphWeaver::TransportError, "#{e.class}: #{message}"
|
|
32
52
|
end
|
|
33
53
|
end
|
|
54
|
+
private_class_method :network_failure
|
|
34
55
|
|
|
35
56
|
# The server answered non-2xx. headers: is where the answer to "wait,
|
|
36
57
|
# then" lives — ServerError#retry_after and #throttled? read it, so a
|
|
@@ -41,24 +62,60 @@ module GraphWeaver
|
|
|
41
62
|
FailureClient.new { raise GraphWeaver::ServerError.new(status:, body:, headers:) }
|
|
42
63
|
end
|
|
43
64
|
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
65
|
+
# The wire fields an error carries, beyond its message. `code:` is the
|
|
66
|
+
# sugar fail_at: already uses — extensions.code, the one every server
|
|
67
|
+
# states. Anything else is refused by name: a swallowed keyword leaves a
|
|
68
|
+
# simulated failure that doesn't simulate what the example asked for.
|
|
69
|
+
ERROR_FIELDS = %i[code extensions path locations].freeze
|
|
70
|
+
private_constant :ERROR_FIELDS
|
|
71
|
+
|
|
72
|
+
# Top-level GraphQL errors — a **whole-response** failure unless data:
|
|
73
|
+
# rides along. Each positional is a String (just the message) or a Hash
|
|
74
|
+
# in the wire error shape; the fields of ONE error may be named beside
|
|
75
|
+
# its message instead:
|
|
76
|
+
#
|
|
77
|
+
# Failure.graphql("boom")
|
|
78
|
+
# Failure.graphql("boom", code: "BAD_USER_INPUT", path: ["adopt"])
|
|
79
|
+
# Failure.graphql("min must be at least 1", code: "BAD_USER_INPUT",
|
|
80
|
+
# extensions: { "input" => { "kind" => "out_of_range", "min" => 1 } })
|
|
81
|
+
# Failure.graphql({ message: "a", path: ["x"] }, { message: "b" }, data: { "x" => nil })
|
|
82
|
+
def graphql(*errors, data: nil, **fields)
|
|
83
|
+
unknown = fields.keys - ERROR_FIELDS
|
|
84
|
+
unless unknown.empty?
|
|
85
|
+
raise ArgumentError, "Failure.graphql: unknown keyword(s) #{unknown.join(", ")} — " \
|
|
86
|
+
"expected data:, or #{ERROR_FIELDS.join(", ")} to shape the error"
|
|
49
87
|
end
|
|
50
88
|
|
|
51
|
-
|
|
89
|
+
errors = errors.flatten
|
|
90
|
+
unless fields.empty? || errors.one?
|
|
91
|
+
raise ArgumentError, "Failure.graphql: #{fields.keys.join(", ")} shapes one error, " \
|
|
92
|
+
"got #{errors.size} — give each its own hash"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
response = { "errors" => errors.map { |error| wire_error(error, fields) } }
|
|
52
96
|
response["data"] = data if data
|
|
53
|
-
response["extensions"] = JSON.parse(JSON.generate(extensions)) unless extensions.empty?
|
|
54
97
|
FailureClient.new { response }
|
|
55
98
|
end
|
|
56
99
|
|
|
100
|
+
# a String is its message; a Hash is the wire error as written. The
|
|
101
|
+
# kwargs merge on top, so `code:` and `extensions:` compose.
|
|
102
|
+
def wire_error(error, fields)
|
|
103
|
+
wire = error.is_a?(String) ? { "message" => error } : JSON.parse(JSON.generate(error))
|
|
104
|
+
return wire if fields.empty?
|
|
105
|
+
|
|
106
|
+
extensions = JSON.parse(JSON.generate(fields[:extensions] || {}))
|
|
107
|
+
extensions["code"] = fields[:code].to_s if fields[:code]
|
|
108
|
+
wire.merge!(JSON.parse(JSON.generate(fields.slice(:path, :locations))))
|
|
109
|
+
wire["extensions"] = (wire["extensions"] || {}).merge(extensions) unless extensions.empty?
|
|
110
|
+
wire
|
|
111
|
+
end
|
|
112
|
+
private_class_method :wire_error
|
|
113
|
+
|
|
57
114
|
def throttled
|
|
58
115
|
# a code from the list #throttled? recognizes, not one spelled here —
|
|
59
116
|
# a fake that doesn't trip the predicate it exists to exercise is worse
|
|
60
|
-
# than no fake
|
|
61
|
-
graphql(
|
|
117
|
+
# than no fake
|
|
118
|
+
graphql("rate limited", code: GraphWeaver::GraphQLError::THROTTLE_CODES.first)
|
|
62
119
|
end
|
|
63
120
|
|
|
64
121
|
# A validation-shaped rejection — trips schema_stale? and its
|
|
@@ -30,13 +30,15 @@ require_relative "../parsing"
|
|
|
30
30
|
# takes one. Keys are checked against the schema, since a typo'd one would
|
|
31
31
|
# pin nothing and leave the test green. (A pin with a wrong-typed value is
|
|
32
32
|
# also the way to simulate a corrupt payload — casting raises
|
|
33
|
-
# GraphWeaver::
|
|
33
|
+
# GraphWeaver::CastError.)
|
|
34
34
|
#
|
|
35
35
|
# FakeClient.new({ "Money" => "12.00", "Person" => build(:person),
|
|
36
36
|
# "email" => -> { "test@example.com" } }, schema:)
|
|
37
37
|
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
38
|
+
# Pins and options are the same keywords, told apart by a lookup: a key
|
|
39
|
+
# this fake takes is an option, a key your schema knows is a pin, and a key
|
|
40
|
+
# that is neither is refused naming both. So a lowercase type pins as
|
|
41
|
+
# readily as a capitalized one. `overrides:` is the same hash by keyword,
|
|
40
42
|
# and the leading one wins where both name a key.
|
|
41
43
|
#
|
|
42
44
|
# A pin covers a whole subtree as readily as a leaf, and **merges** rather
|
|
@@ -53,6 +55,11 @@ require_relative "../parsing"
|
|
|
53
55
|
# other way round: the reader is the snake_cased field name, not the alias,
|
|
54
56
|
# and a field it doesn't answer is fabricated.
|
|
55
57
|
#
|
|
58
|
+
# registry: whose register_scalar/register_enum calls the fabricated values
|
|
59
|
+
# have to satisfy — GraphWeaver::Graph#registry, since a Money registered
|
|
60
|
+
# for one graph is not a Money for the next. Left unsaid it is read back off
|
|
61
|
+
# schema:, which is the answer for every app with one graph.
|
|
62
|
+
#
|
|
56
63
|
# requests: every execute, in order ({ query:, variables:, operation_name: })
|
|
57
64
|
# — "did we send the right variables", and "did we call it at all".
|
|
58
65
|
#
|
|
@@ -65,11 +72,16 @@ require_relative "../parsing"
|
|
|
65
72
|
# FakeClient.new(schema:, fail_at: "person.pets.name")
|
|
66
73
|
# FakeClient.new(schema:, fail_at: { path: "person.email", message: "hidden", code: "PRIVATE" })
|
|
67
74
|
#
|
|
75
|
+
# The path is response keys joined by dots, and a list index is a segment
|
|
76
|
+
# of its own — "people.0.pets.1.name". State only the indices you mean;
|
|
77
|
+
# the rest match any position, so "people.pets.name" fails the first
|
|
78
|
+
# element the walk reaches.
|
|
79
|
+
#
|
|
68
80
|
# errors: appends verbatim top-level errors alongside the fake data.
|
|
69
81
|
#
|
|
70
82
|
# Type mismatches: corrupt: names fields ("Type.field") that should
|
|
71
83
|
# arrive wire-corrupted — a wrong-typed value derived from the schema,
|
|
72
|
-
# so casting raises GraphWeaver::
|
|
84
|
+
# so casting raises GraphWeaver::CastError. One spec checks the failure
|
|
73
85
|
# path; every other spec gets working data:
|
|
74
86
|
#
|
|
75
87
|
# FakeClient.new(schema:, corrupt: "Person.birthday")
|
|
@@ -81,6 +93,14 @@ require_relative "../parsing"
|
|
|
81
93
|
#
|
|
82
94
|
# FakeClient.new(schema:, null_chance: 1.0) # everything nullable, null
|
|
83
95
|
#
|
|
96
|
+
# list_size: how long an unbounded list is — an Integer exactly, a Range
|
|
97
|
+
# randomized within it, and a Hash per list, keyed the way a pin is (a
|
|
98
|
+
# "Type.field" coordinate or a bare field name) with "default" for the rest.
|
|
99
|
+
# Every list the walk reaches reads this, so nested lists MULTIPLY under one
|
|
100
|
+
# number: n rows each fabricate n tags. Naming the inner one flattens that.
|
|
101
|
+
#
|
|
102
|
+
# FakeClient.new(schema:, list_size: { "Row.tags" => 3, default: 500 })
|
|
103
|
+
#
|
|
84
104
|
# seed: makes a run reproducible (also seeds faker). schema:, overrides:
|
|
85
105
|
# and list_size: fall back to GraphWeaver::Testing.config — and the
|
|
86
106
|
# config's schema falls back to the committed dump.
|
|
@@ -113,44 +133,49 @@ class GraphWeaver::Testing::FakeClient
|
|
|
113
133
|
# misspelled key arrived as a bare "unknown keyword" from inside the
|
|
114
134
|
# fabricator, naming neither the accepted options nor the one you meant.
|
|
115
135
|
OPTIONS = {
|
|
116
|
-
schema: nil, overrides: {}, seed: nil, values: nil, list_size: nil,
|
|
136
|
+
schema: nil, registry: nil, overrides: {}, seed: nil, values: nil, list_size: nil,
|
|
117
137
|
null_chance: nil, errors: nil, fail_at: nil, corrupt: nil,
|
|
118
138
|
}.freeze
|
|
119
139
|
|
|
120
|
-
# One rule tells a pin from an option: options are lowercase words, and
|
|
121
|
-
# anything with a dot or a leading capital names something in the schema.
|
|
122
|
-
# Ruby 3 hands every braceless pair to **options — String keys included —
|
|
123
|
-
# so `FakeClient.new("Order.total" => "9", seed: 1)` arrives whole and is
|
|
124
|
-
# split here, as is a quoted symbol (`"Order.total":`) or a hash forwarded
|
|
125
|
-
# by a router's fake:.
|
|
126
|
-
PIN_KEY = /\A[A-Z]|\./
|
|
127
|
-
|
|
128
140
|
# JSON's own types are already on the wire: at a leaf they skip the
|
|
129
|
-
# registry's serializer, and at a composite position (a Hash
|
|
130
|
-
# is response keys) they pin the field as written — nil is
|
|
131
|
-
# is the corrupt payload the example asked for.
|
|
132
|
-
WIRE =
|
|
141
|
+
# registry's serializer (Values#wire), and at a composite position (a Hash
|
|
142
|
+
# aside, which is response keys) they pin the field as written — nil is
|
|
143
|
+
# null, the rest is the corrupt payload the example asked for.
|
|
144
|
+
WIRE = GraphWeaver::Internal::Values::WIRE
|
|
133
145
|
|
|
134
146
|
# Methods every Ruby object answers aren't fields: a schema does have a
|
|
135
147
|
# `hash` or a `count`, and a Struct answers both with plausible nonsense
|
|
136
148
|
# where fabricating is right.
|
|
137
149
|
RUBY_OWN = [BasicObject, Kernel, Object, Comparable, Enumerable, Struct, Data].freeze
|
|
138
|
-
|
|
150
|
+
|
|
151
|
+
# The scalars the GraphQL spec serializes as JSON strings, whatever Ruby
|
|
152
|
+
# holds them.
|
|
153
|
+
STRING_SCALARS = %w[ID String].freeze
|
|
154
|
+
private_constant :OPTIONS, :WIRE, :RUBY_OWN, :STRING_SCALARS
|
|
139
155
|
|
|
140
156
|
def initialize(pins = {}, **options)
|
|
141
|
-
pins, options = check_options!(pins, options)
|
|
142
157
|
config = GraphWeaver::Testing.config
|
|
158
|
+
# resolved before the split, because the split asks the schema which keys
|
|
159
|
+
# are pins
|
|
143
160
|
@schema = options[:schema] || config.schema || raise(GraphWeaver::Error,
|
|
144
161
|
"no schema to fake against — set GraphWeaver::Testing.config.schema, pass schema:, " \
|
|
145
162
|
"or commit a schema dump at #{GraphWeaver.schema_path}")
|
|
163
|
+
pins, options = check_options!(pins, options)
|
|
146
164
|
# last wins, narrowest last: the suite's, then overrides:, then the pins
|
|
147
165
|
# this fake was handed outright
|
|
148
166
|
@overrides = [config.overrides, options[:overrides], pins]
|
|
149
167
|
.map { |hash| hash.transform_keys(&:to_s) }.reduce(:merge)
|
|
150
168
|
GraphWeaver::Internal::Overrides.validate!(@schema, @overrides)
|
|
169
|
+
# A graph whose schema is a file can't be matched back off the schema
|
|
170
|
+
# object — SchemaLoader builds a fresh anonymous class each load — so a
|
|
171
|
+
# caller holding the graph passes its registry rather than letting the
|
|
172
|
+
# lookup fall through to the default one.
|
|
173
|
+
@registry = options[:registry] || GraphWeaver::Internal::Util.registry_for(@schema)
|
|
151
174
|
@values = GraphWeaver::Internal::Values.new(seed: options[:seed], values: options[:values],
|
|
152
|
-
pins: @overrides)
|
|
175
|
+
pins: @overrides, schema: @schema, registry: @registry)
|
|
153
176
|
@list_size = options[:list_size] || config.list_size
|
|
177
|
+
@list_size = @list_size.transform_keys(&:to_s) if @list_size.is_a?(Hash)
|
|
178
|
+
GraphWeaver::Internal::Overrides.validate_list_size!(@schema, @list_size)
|
|
154
179
|
@null_chance = options[:null_chance] || 0.0
|
|
155
180
|
# NOT Array(): it would explode a bare Hash into key/value pairs
|
|
156
181
|
@extra_errors = wrap(options[:errors]).map { |error| normalize_error(error) }
|
|
@@ -229,21 +254,39 @@ class GraphWeaver::Testing::FakeClient
|
|
|
229
254
|
|
|
230
255
|
private
|
|
231
256
|
|
|
232
|
-
#
|
|
233
|
-
#
|
|
257
|
+
# One rule tells a pin from an option, and it is a lookup rather than a
|
|
258
|
+
# guess at spelling: a key this fake takes is an option, a key the schema
|
|
259
|
+
# knows is a pin, and a key that is neither is a typo — refused naming both
|
|
260
|
+
# dictionaries, since only the author knows which they were reaching for. A
|
|
261
|
+
# misspelled option would otherwise pin nothing and leave the example green.
|
|
262
|
+
#
|
|
263
|
+
# Ruby 3 hands every braceless pair to **options — String keys included —
|
|
264
|
+
# so `FakeClient.new("Order.total" => "9", seed: 1)` arrives whole and is
|
|
265
|
+
# split here, as is a quoted symbol (`"Order.total":`) or a hash forwarded
|
|
266
|
+
# by a router's fake:. A leading positional hash is only ever pins, which
|
|
267
|
+
# is the spelling for a schema whose own vocabulary collides with an
|
|
268
|
+
# option name.
|
|
234
269
|
def check_options!(pins, options)
|
|
235
|
-
options, keyed_pins = options.partition { |key, _|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
pins = keyed_pins.merge(pins.to_h)
|
|
270
|
+
options, keyed_pins = options.partition { |key, _| OPTIONS.key?(key) }.map(&:to_h)
|
|
271
|
+
unknown = keyed_pins.keys.reject { |key| GraphWeaver::Internal::Overrides.schema_reference?(@schema, key) }
|
|
272
|
+
refuse_key!(unknown.first) if unknown.any?
|
|
239
273
|
|
|
240
|
-
|
|
241
|
-
|
|
274
|
+
[keyed_pins.merge(pins.to_h), OPTIONS.merge(options)]
|
|
275
|
+
end
|
|
242
276
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
277
|
+
def refuse_key!(key)
|
|
278
|
+
dictionary = OPTIONS.keys.map(&:to_s) + GraphWeaver::Internal::Overrides.pin_names(@schema)
|
|
279
|
+
suggestion = GraphWeaver::Internal::Util.did_you_mean(dictionary, key.to_s)
|
|
280
|
+
hint = if suggestion.nil?
|
|
281
|
+
"."
|
|
282
|
+
elsif OPTIONS.key?(suggestion.to_sym)
|
|
283
|
+
" — did you mean #{suggestion}:?"
|
|
284
|
+
else
|
|
285
|
+
" — did you mean the pin #{suggestion.inspect}?"
|
|
286
|
+
end
|
|
287
|
+
raise ArgumentError, "a fake doesn't take #{key}:#{hint} It takes " \
|
|
288
|
+
"#{OPTIONS.keys.map { |name| "#{name}:" }.join(", ")}, and pins keyed by anything in your " \
|
|
289
|
+
"schema — a type, a \"Type.field\" coordinate, or a field name"
|
|
247
290
|
end
|
|
248
291
|
|
|
249
292
|
def rng = @values.rng
|
|
@@ -298,7 +341,37 @@ class GraphWeaver::Testing::FakeClient
|
|
|
298
341
|
end
|
|
299
342
|
|
|
300
343
|
def normalize_fail_spec(spec)
|
|
301
|
-
spec.is_a?(String) ? { "path" => spec } : JSON.parse(JSON.generate(spec))
|
|
344
|
+
normalized = spec.is_a?(String) ? { "path" => spec } : JSON.parse(JSON.generate(spec))
|
|
345
|
+
normalized["chain"] = fail_chain(normalized["path"])
|
|
346
|
+
normalized
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# A fail_at path as (field, indices) pairs: "people.0.pets.name" is people
|
|
350
|
+
# at index 0, then pets at any index, then name. An index you state has to
|
|
351
|
+
# match; one you leave out matches every position, so the plain
|
|
352
|
+
# "people.pets.name" fails the first element the walk reaches — which is
|
|
353
|
+
# what it has always done. Silently ignoring an index was the alternative,
|
|
354
|
+
# and a fail_at that never fires looks exactly like a passing test.
|
|
355
|
+
def fail_chain(path)
|
|
356
|
+
unless path.is_a?(String) && !path.empty?
|
|
357
|
+
raise ArgumentError, "fail_at: expected a response path like \"person.email\", got #{path.inspect}"
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
segments = path.split(".").map { |segment| segment.match?(/\A\d+\z/) ? Integer(segment) : segment }
|
|
361
|
+
if segments.first.is_a?(Integer)
|
|
362
|
+
raise ArgumentError, "fail_at: #{path.inspect} starts with a list index — a path starts with a field"
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
path_chain(segments)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# the shared fold: a fail_at path and the walk's own @path become the same
|
|
369
|
+
# shape, so one comparison serves both
|
|
370
|
+
def path_chain(segments)
|
|
371
|
+
segments.each_with_object([]) do |segment, chain|
|
|
372
|
+
field = chain.last
|
|
373
|
+
field && segment.is_a?(Integer) ? field.last << segment : chain << [segment, []]
|
|
374
|
+
end
|
|
302
375
|
end
|
|
303
376
|
|
|
304
377
|
# pins: the response keys an override pinned at this object, merged in as
|
|
@@ -433,16 +506,31 @@ class GraphWeaver::Testing::FakeClient
|
|
|
433
506
|
case type.kind.name
|
|
434
507
|
when "NON_NULL" then wire_value(type.of_type, value, coordinate)
|
|
435
508
|
when "LIST"
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
509
|
+
# whatever enumerates, not an Array alone: a has_many is an
|
|
510
|
+
# ActiveRecord CollectionProxy, and reading one straight onto the wire
|
|
511
|
+
# failed the cast as "the server sent a
|
|
512
|
+
# Order::ActiveRecord_Associations_CollectionProxy". A Hash is the one
|
|
513
|
+
# thing that enumerates and isn't a list.
|
|
514
|
+
return value if value.is_a?(Hash) || !value.is_a?(Enumerable)
|
|
515
|
+
|
|
516
|
+
value.map { |element| wire_value(type.of_type, element, coordinate) }
|
|
517
|
+
when "SCALAR" then scalar_wire(type.graphql_name, value, coordinate)
|
|
441
518
|
when "ENUM" then value.is_a?(T::Enum) ? value.serialize : value
|
|
442
519
|
else value # a composite: pinned_object reads it, one level down
|
|
443
520
|
end
|
|
444
521
|
end
|
|
445
522
|
|
|
523
|
+
# ID and String are JSON strings on every real wire, whatever Ruby type the
|
|
524
|
+
# object's column holds — an Integer primary key read straight through
|
|
525
|
+
# failed the cast with the advice for a server that sends ids unquoted,
|
|
526
|
+
# which is advice about a server that isn't there.
|
|
527
|
+
def scalar_wire(name, value, coordinate)
|
|
528
|
+
wired = @values.wire(name, value, coordinate)
|
|
529
|
+
return wired unless STRING_SCALARS.include?(name) && !wired.nil? && !wired.is_a?(String)
|
|
530
|
+
|
|
531
|
+
wired.to_s
|
|
532
|
+
end
|
|
533
|
+
|
|
446
534
|
# The concrete type a pinned object is fabricated as. At a union or
|
|
447
535
|
# interface the pin has to say: picking a member at random would fabricate
|
|
448
536
|
# a shape the pinned keys don't fit, in whichever fraction of runs the
|
|
@@ -506,24 +594,55 @@ class GraphWeaver::Testing::FakeClient
|
|
|
506
594
|
end
|
|
507
595
|
end
|
|
508
596
|
|
|
509
|
-
# first untriggered fail_at spec whose
|
|
510
|
-
# matches where we are
|
|
597
|
+
# first untriggered fail_at spec whose chain matches where we are
|
|
511
598
|
def matching_failure
|
|
512
|
-
|
|
513
|
-
@fail_at.find { |spec| !spec["triggered"] && spec["
|
|
599
|
+
here = path_chain(@path)
|
|
600
|
+
@fail_at.find { |spec| !spec["triggered"] && at?(spec["chain"], here) }
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def at?(chain, here)
|
|
604
|
+
return false unless chain.size == here.size
|
|
605
|
+
|
|
606
|
+
chain.zip(here).all? do |(field, indices), (at, positions)|
|
|
607
|
+
field == at && indices.each_with_index.all? { |index, depth| positions[depth] == index }
|
|
608
|
+
end
|
|
514
609
|
end
|
|
515
610
|
|
|
516
611
|
# honor pagination-ish arg semantics: first/last/limit caps the fabricated
|
|
517
612
|
# list length, whether it arrives as a literal or as a variable
|
|
518
|
-
def list_length(node)
|
|
613
|
+
def list_length(node, coordinate)
|
|
519
614
|
argument = node.arguments.find { |arg| %w[first last limit].include?(arg.name) }
|
|
520
615
|
capped = argument && argument_value(argument)
|
|
521
616
|
# Array.new(-1) is "negative array size" out of the fabricator's guts; a
|
|
522
617
|
# cap below zero asks for nothing, which is what a page of none is
|
|
523
618
|
return [capped, 0].max if capped.is_a?(Integer)
|
|
619
|
+
return 0 if errors_list?(node.name)
|
|
524
620
|
|
|
621
|
+
size = list_size_for(coordinate, node.name)
|
|
525
622
|
# an Integer list_size means exactly that many; a Range randomizes within it
|
|
526
|
-
|
|
623
|
+
size.is_a?(Range) ? rng.rand(size) : size
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# A list field whose name ends in `errors` fabricates empty. The Relay and
|
|
627
|
+
# Shopify payload convention — `placeOrder { order userErrors }` — otherwise
|
|
628
|
+
# comes back with a fabricated order AND a fabricated failure, which is a
|
|
629
|
+
# response no server can send, so the natural happy-path assertion is flaky
|
|
630
|
+
# until it is pinned. Pin it to fabricate the failure path.
|
|
631
|
+
def errors_list?(name) = name.downcase.end_with?("errors")
|
|
632
|
+
|
|
633
|
+
# How long an unbounded list is. A Hash says it per list, read most
|
|
634
|
+
# specific first like a pin — which is what keeps nested lists from
|
|
635
|
+
# multiplying: every list the walk reaches re-reads this, so one number
|
|
636
|
+
# for all of them is n rows x n tags.
|
|
637
|
+
def list_size_for(coordinate, name)
|
|
638
|
+
return @list_size unless @list_size.is_a?(Hash)
|
|
639
|
+
|
|
640
|
+
@list_size.fetch(coordinate) do
|
|
641
|
+
@list_size.fetch(name) do
|
|
642
|
+
@list_size.fetch(GraphWeaver::Internal::Overrides::LIST_SIZE_DEFAULT,
|
|
643
|
+
GraphWeaver::Testing::Config::DEFAULT_LIST_SIZE)
|
|
644
|
+
end
|
|
645
|
+
end
|
|
527
646
|
end
|
|
528
647
|
|
|
529
648
|
def type_value(type, node, selections, coordinate: nil, non_null: false)
|
|
@@ -536,7 +655,7 @@ class GraphWeaver::Testing::FakeClient
|
|
|
536
655
|
|
|
537
656
|
case type.kind.name
|
|
538
657
|
when "LIST"
|
|
539
|
-
elements = Array.new(list_length(node)) do |index|
|
|
658
|
+
elements = Array.new(list_length(node, coordinate)) do |index|
|
|
540
659
|
@path.push(index)
|
|
541
660
|
element = type_value(type.of_type, node, selections, coordinate:)
|
|
542
661
|
@path.pop
|