graph_weaver 0.6.1 → 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 +1447 -1
- data/Gemfile +8 -0
- data/Gemfile.lock +151 -2
- data/README.md +20 -6
- 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 +497 -104
- data/docs/i18n.md +234 -0
- data/docs/logging.md +160 -24
- data/docs/real_world.md +28 -0
- data/docs/scalars.md +190 -26
- data/docs/testing.md +457 -58
- data/docs/transports.md +164 -19
- data/docs/upgrading.md +328 -3
- 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 +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +404 -197
- 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 +40 -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 +474 -106
- metadata +56 -1
|
@@ -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
|
|
@@ -160,7 +161,7 @@ module GraphWeaver
|
|
|
160
161
|
# and nothing else notices when that server's answers drift out of the
|
|
161
162
|
# shape the structs were generated for: `verify`, `queries:check` and
|
|
162
163
|
# `schema:diff` all ask about the local side. Without this the drift
|
|
163
|
-
# surfaces mid-spec as a `
|
|
164
|
+
# surfaces mid-spec as a `CastError` naming a struct and a sorbet
|
|
164
165
|
# frame, with nothing pointing at the stale file.
|
|
165
166
|
#
|
|
166
167
|
# Matching is on the query text, which is the module that sent it — a
|
|
@@ -281,7 +282,7 @@ module GraphWeaver
|
|
|
281
282
|
|
|
282
283
|
def initialize(schema:, seed: nil, values: nil)
|
|
283
284
|
@schema = schema
|
|
284
|
-
@values = Internal::Values.new(seed:, values:)
|
|
285
|
+
@values = Internal::Values.new(seed:, values:, schema:)
|
|
285
286
|
end
|
|
286
287
|
|
|
287
288
|
# The whole response, not just `data`: an error message routinely
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
class Endpoint
|
|
31
|
+
JSON_HEADERS = { "content-type" => "application/json" }.freeze
|
|
32
|
+
TEXT_HEADERS = { "content-type" => "text/plain" }.freeze
|
|
33
|
+
private_constant :JSON_HEADERS, :TEXT_HEADERS
|
|
34
|
+
|
|
35
|
+
# how much of an unservable body the 400 quotes back
|
|
36
|
+
EXCERPT = 200
|
|
37
|
+
private_constant :EXCERPT
|
|
38
|
+
|
|
39
|
+
def initialize(client)
|
|
40
|
+
@client = client
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call(env)
|
|
44
|
+
method = env["REQUEST_METHOD"]
|
|
45
|
+
return refuse("expected a POST of a GraphQL request, got #{method}") unless method == "POST"
|
|
46
|
+
|
|
47
|
+
body = env["rack.input"]&.read.to_s
|
|
48
|
+
request = begin
|
|
49
|
+
JSON.parse(body)
|
|
50
|
+
rescue JSON::ParserError => e
|
|
51
|
+
return refuse("expected a JSON GraphQL request body, got #{excerpt(body)} (#{e.message})")
|
|
52
|
+
end
|
|
53
|
+
unless request.is_a?(Hash) && request["query"].is_a?(String)
|
|
54
|
+
return refuse("expected a JSON GraphQL request body with a \"query\" string, got #{excerpt(body)}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
result = with_context(headers(env)) do
|
|
58
|
+
@client.execute(request["query"], variables: request["variables"] || {},
|
|
59
|
+
operation_name: request["operationName"])
|
|
60
|
+
end
|
|
61
|
+
[200, JSON_HEADERS, [JSON.generate(result)]]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# never leak the client's context (tokens, current_user)
|
|
65
|
+
def inspect = "#<#{self.class.name} client=#{@client.class}>"
|
|
66
|
+
alias to_s inspect
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# A `context:` proc is answered from the request in hand, so it is
|
|
71
|
+
# resolved here and put back after — one request's identity must not
|
|
72
|
+
# leak into the next. A client with no context, or a hash one, is
|
|
73
|
+
# served untouched.
|
|
74
|
+
def with_context(headers)
|
|
75
|
+
context = @client.context if @client.respond_to?(:context) && @client.respond_to?(:context=)
|
|
76
|
+
return yield unless context.respond_to?(:call)
|
|
77
|
+
|
|
78
|
+
@client.context = context.call(headers)
|
|
79
|
+
begin
|
|
80
|
+
yield
|
|
81
|
+
ensure
|
|
82
|
+
@client.context = context
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Rack spells a header HTTP_X_CALLER; the proc reads "X-Caller".
|
|
87
|
+
# Capitalization is reconstructed, not remembered — the CGI env
|
|
88
|
+
# dropped it — so a header sent as X-CALLER arrives here as X-Caller.
|
|
89
|
+
def headers(env)
|
|
90
|
+
env.each_with_object({}) do |(key, value), headers|
|
|
91
|
+
name = case key
|
|
92
|
+
when /\AHTTP_(.+)\z/ then Regexp.last_match(1)
|
|
93
|
+
when "CONTENT_TYPE", "CONTENT_LENGTH" then key
|
|
94
|
+
end
|
|
95
|
+
next unless name && value.is_a?(String)
|
|
96
|
+
|
|
97
|
+
headers[name.downcase.split("_").map(&:capitalize).join("-")] = value
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def excerpt(body) = body.empty? ? "an empty body" : body[0, EXCERPT].inspect
|
|
102
|
+
|
|
103
|
+
def refuse(message) = [400, TEXT_HEADERS, [message]]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
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,45 @@ 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
|
-
private_constant :OPTIONS, :
|
|
150
|
+
private_constant :OPTIONS, :WIRE, :RUBY_OWN
|
|
139
151
|
|
|
140
152
|
def initialize(pins = {}, **options)
|
|
141
|
-
pins, options = check_options!(pins, options)
|
|
142
153
|
config = GraphWeaver::Testing.config
|
|
154
|
+
# resolved before the split, because the split asks the schema which keys
|
|
155
|
+
# are pins
|
|
143
156
|
@schema = options[:schema] || config.schema || raise(GraphWeaver::Error,
|
|
144
157
|
"no schema to fake against — set GraphWeaver::Testing.config.schema, pass schema:, " \
|
|
145
158
|
"or commit a schema dump at #{GraphWeaver.schema_path}")
|
|
159
|
+
pins, options = check_options!(pins, options)
|
|
146
160
|
# last wins, narrowest last: the suite's, then overrides:, then the pins
|
|
147
161
|
# this fake was handed outright
|
|
148
162
|
@overrides = [config.overrides, options[:overrides], pins]
|
|
149
163
|
.map { |hash| hash.transform_keys(&:to_s) }.reduce(:merge)
|
|
150
164
|
GraphWeaver::Internal::Overrides.validate!(@schema, @overrides)
|
|
165
|
+
# A graph whose schema is a file can't be matched back off the schema
|
|
166
|
+
# object — SchemaLoader builds a fresh anonymous class each load — so a
|
|
167
|
+
# caller holding the graph passes its registry rather than letting the
|
|
168
|
+
# lookup fall through to the default one.
|
|
169
|
+
@registry = options[:registry] || GraphWeaver::Internal::Util.registry_for(@schema)
|
|
151
170
|
@values = GraphWeaver::Internal::Values.new(seed: options[:seed], values: options[:values],
|
|
152
|
-
pins: @overrides)
|
|
171
|
+
pins: @overrides, schema: @schema, registry: @registry)
|
|
153
172
|
@list_size = options[:list_size] || config.list_size
|
|
173
|
+
@list_size = @list_size.transform_keys(&:to_s) if @list_size.is_a?(Hash)
|
|
174
|
+
GraphWeaver::Internal::Overrides.validate_list_size!(@schema, @list_size)
|
|
154
175
|
@null_chance = options[:null_chance] || 0.0
|
|
155
176
|
# NOT Array(): it would explode a bare Hash into key/value pairs
|
|
156
177
|
@extra_errors = wrap(options[:errors]).map { |error| normalize_error(error) }
|
|
@@ -229,21 +250,39 @@ class GraphWeaver::Testing::FakeClient
|
|
|
229
250
|
|
|
230
251
|
private
|
|
231
252
|
|
|
232
|
-
#
|
|
233
|
-
#
|
|
253
|
+
# One rule tells a pin from an option, and it is a lookup rather than a
|
|
254
|
+
# guess at spelling: a key this fake takes is an option, a key the schema
|
|
255
|
+
# knows is a pin, and a key that is neither is a typo — refused naming both
|
|
256
|
+
# dictionaries, since only the author knows which they were reaching for. A
|
|
257
|
+
# misspelled option would otherwise pin nothing and leave the example green.
|
|
258
|
+
#
|
|
259
|
+
# Ruby 3 hands every braceless pair to **options — String keys included —
|
|
260
|
+
# so `FakeClient.new("Order.total" => "9", seed: 1)` arrives whole and is
|
|
261
|
+
# split here, as is a quoted symbol (`"Order.total":`) or a hash forwarded
|
|
262
|
+
# by a router's fake:. A leading positional hash is only ever pins, which
|
|
263
|
+
# is the spelling for a schema whose own vocabulary collides with an
|
|
264
|
+
# option name.
|
|
234
265
|
def check_options!(pins, options)
|
|
235
|
-
options, keyed_pins = options.partition { |key, _|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
pins = keyed_pins.merge(pins.to_h)
|
|
266
|
+
options, keyed_pins = options.partition { |key, _| OPTIONS.key?(key) }.map(&:to_h)
|
|
267
|
+
unknown = keyed_pins.keys.reject { |key| GraphWeaver::Internal::Overrides.schema_reference?(@schema, key) }
|
|
268
|
+
refuse_key!(unknown.first) if unknown.any?
|
|
239
269
|
|
|
240
|
-
|
|
241
|
-
|
|
270
|
+
[keyed_pins.merge(pins.to_h), OPTIONS.merge(options)]
|
|
271
|
+
end
|
|
242
272
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
273
|
+
def refuse_key!(key)
|
|
274
|
+
dictionary = OPTIONS.keys.map(&:to_s) + GraphWeaver::Internal::Overrides.pin_names(@schema)
|
|
275
|
+
suggestion = GraphWeaver::Internal::Util.did_you_mean(dictionary, key.to_s)
|
|
276
|
+
hint = if suggestion.nil?
|
|
277
|
+
"."
|
|
278
|
+
elsif OPTIONS.key?(suggestion.to_sym)
|
|
279
|
+
" — did you mean #{suggestion}:?"
|
|
280
|
+
else
|
|
281
|
+
" — did you mean the pin #{suggestion.inspect}?"
|
|
282
|
+
end
|
|
283
|
+
raise ArgumentError, "a fake doesn't take #{key}:#{hint} It takes " \
|
|
284
|
+
"#{OPTIONS.keys.map { |name| "#{name}:" }.join(", ")}, and pins keyed by anything in your " \
|
|
285
|
+
"schema — a type, a \"Type.field\" coordinate, or a field name"
|
|
247
286
|
end
|
|
248
287
|
|
|
249
288
|
def rng = @values.rng
|
|
@@ -298,7 +337,37 @@ class GraphWeaver::Testing::FakeClient
|
|
|
298
337
|
end
|
|
299
338
|
|
|
300
339
|
def normalize_fail_spec(spec)
|
|
301
|
-
spec.is_a?(String) ? { "path" => spec } : JSON.parse(JSON.generate(spec))
|
|
340
|
+
normalized = spec.is_a?(String) ? { "path" => spec } : JSON.parse(JSON.generate(spec))
|
|
341
|
+
normalized["chain"] = fail_chain(normalized["path"])
|
|
342
|
+
normalized
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# A fail_at path as (field, indices) pairs: "people.0.pets.name" is people
|
|
346
|
+
# at index 0, then pets at any index, then name. An index you state has to
|
|
347
|
+
# match; one you leave out matches every position, so the plain
|
|
348
|
+
# "people.pets.name" fails the first element the walk reaches — which is
|
|
349
|
+
# what it has always done. Silently ignoring an index was the alternative,
|
|
350
|
+
# and a fail_at that never fires looks exactly like a passing test.
|
|
351
|
+
def fail_chain(path)
|
|
352
|
+
unless path.is_a?(String) && !path.empty?
|
|
353
|
+
raise ArgumentError, "fail_at: expected a response path like \"person.email\", got #{path.inspect}"
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
segments = path.split(".").map { |segment| segment.match?(/\A\d+\z/) ? Integer(segment) : segment }
|
|
357
|
+
if segments.first.is_a?(Integer)
|
|
358
|
+
raise ArgumentError, "fail_at: #{path.inspect} starts with a list index — a path starts with a field"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
path_chain(segments)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# the shared fold: a fail_at path and the walk's own @path become the same
|
|
365
|
+
# shape, so one comparison serves both
|
|
366
|
+
def path_chain(segments)
|
|
367
|
+
segments.each_with_object([]) do |segment, chain|
|
|
368
|
+
field = chain.last
|
|
369
|
+
field && segment.is_a?(Integer) ? field.last << segment : chain << [segment, []]
|
|
370
|
+
end
|
|
302
371
|
end
|
|
303
372
|
|
|
304
373
|
# pins: the response keys an override pinned at this object, merged in as
|
|
@@ -434,10 +503,7 @@ class GraphWeaver::Testing::FakeClient
|
|
|
434
503
|
when "NON_NULL" then wire_value(type.of_type, value, coordinate)
|
|
435
504
|
when "LIST"
|
|
436
505
|
value.is_a?(Array) ? value.map { |element| wire_value(type.of_type, element, coordinate) } : value
|
|
437
|
-
when "SCALAR"
|
|
438
|
-
return value if wire?(value)
|
|
439
|
-
|
|
440
|
-
GraphWeaver::Codegen.scalar(type.graphql_name, coordinate).serialize_value(value)
|
|
506
|
+
when "SCALAR" then @values.wire(type.graphql_name, value, coordinate)
|
|
441
507
|
when "ENUM" then value.is_a?(T::Enum) ? value.serialize : value
|
|
442
508
|
else value # a composite: pinned_object reads it, one level down
|
|
443
509
|
end
|
|
@@ -506,24 +572,47 @@ class GraphWeaver::Testing::FakeClient
|
|
|
506
572
|
end
|
|
507
573
|
end
|
|
508
574
|
|
|
509
|
-
# first untriggered fail_at spec whose
|
|
510
|
-
# matches where we are
|
|
575
|
+
# first untriggered fail_at spec whose chain matches where we are
|
|
511
576
|
def matching_failure
|
|
512
|
-
|
|
513
|
-
@fail_at.find { |spec| !spec["triggered"] && spec["
|
|
577
|
+
here = path_chain(@path)
|
|
578
|
+
@fail_at.find { |spec| !spec["triggered"] && at?(spec["chain"], here) }
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def at?(chain, here)
|
|
582
|
+
return false unless chain.size == here.size
|
|
583
|
+
|
|
584
|
+
chain.zip(here).all? do |(field, indices), (at, positions)|
|
|
585
|
+
field == at && indices.each_with_index.all? { |index, depth| positions[depth] == index }
|
|
586
|
+
end
|
|
514
587
|
end
|
|
515
588
|
|
|
516
589
|
# honor pagination-ish arg semantics: first/last/limit caps the fabricated
|
|
517
590
|
# list length, whether it arrives as a literal or as a variable
|
|
518
|
-
def list_length(node)
|
|
591
|
+
def list_length(node, coordinate)
|
|
519
592
|
argument = node.arguments.find { |arg| %w[first last limit].include?(arg.name) }
|
|
520
593
|
capped = argument && argument_value(argument)
|
|
521
594
|
# Array.new(-1) is "negative array size" out of the fabricator's guts; a
|
|
522
595
|
# cap below zero asks for nothing, which is what a page of none is
|
|
523
596
|
return [capped, 0].max if capped.is_a?(Integer)
|
|
524
597
|
|
|
598
|
+
size = list_size_for(coordinate, node.name)
|
|
525
599
|
# an Integer list_size means exactly that many; a Range randomizes within it
|
|
526
|
-
|
|
600
|
+
size.is_a?(Range) ? rng.rand(size) : size
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
# How long an unbounded list is. A Hash says it per list, read most
|
|
604
|
+
# specific first like a pin — which is what keeps nested lists from
|
|
605
|
+
# multiplying: every list the walk reaches re-reads this, so one number
|
|
606
|
+
# for all of them is n rows x n tags.
|
|
607
|
+
def list_size_for(coordinate, name)
|
|
608
|
+
return @list_size unless @list_size.is_a?(Hash)
|
|
609
|
+
|
|
610
|
+
@list_size.fetch(coordinate) do
|
|
611
|
+
@list_size.fetch(name) do
|
|
612
|
+
@list_size.fetch(GraphWeaver::Internal::Overrides::LIST_SIZE_DEFAULT,
|
|
613
|
+
GraphWeaver::Testing::Config::DEFAULT_LIST_SIZE)
|
|
614
|
+
end
|
|
615
|
+
end
|
|
527
616
|
end
|
|
528
617
|
|
|
529
618
|
def type_value(type, node, selections, coordinate: nil, non_null: false)
|
|
@@ -536,7 +625,7 @@ class GraphWeaver::Testing::FakeClient
|
|
|
536
625
|
|
|
537
626
|
case type.kind.name
|
|
538
627
|
when "LIST"
|
|
539
|
-
elements = Array.new(list_length(node)) do |index|
|
|
628
|
+
elements = Array.new(list_length(node, coordinate)) do |index|
|
|
540
629
|
@path.push(index)
|
|
541
630
|
element = type_value(type.of_type, node, selections, coordinate:)
|
|
542
631
|
@path.pop
|