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
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module GraphWeaver
|
|
5
|
+
module Internal
|
|
6
|
+
# A server's rejection of the input, read back into InputError values —
|
|
7
|
+
# the same value object the client-side refusal is, so an app renders one
|
|
8
|
+
# form the same way whichever side said no.
|
|
9
|
+
#
|
|
10
|
+
# Four shapes, most specific first: the `extensions.input` convention
|
|
11
|
+
# (docs/errors.md), graphql-ruby's variable-coercion `problems` array, a
|
|
12
|
+
# recognized `extensions.code`, and Hasura's argument path. Everything
|
|
13
|
+
# else is nobody's input error and stays out — and an explanation with no
|
|
14
|
+
# table entry becomes `:refused` carrying the server's own sentence,
|
|
15
|
+
# because a wrong `kind` is worse than no kind: the app will have
|
|
16
|
+
# translated it into a confident sentence.
|
|
17
|
+
#
|
|
18
|
+
# One rule for #path throughout: it is the INPUT path the server stated,
|
|
19
|
+
# or empty. A GraphQL error's own path names a selection rather than an
|
|
20
|
+
# input slot, so it is never stood in for one.
|
|
21
|
+
module ServerInput
|
|
22
|
+
# graphql-ruby names the variable only in the error's message; the
|
|
23
|
+
# problems underneath are relative to it (measured against 2.6.10).
|
|
24
|
+
VARIABLE = /\AVariable \$([A-Za-z_]\w*) /
|
|
25
|
+
|
|
26
|
+
# The explanations graphql-ruby writes for a variable that wouldn't
|
|
27
|
+
# coerce. A closed table, not a parser: anything else is :refused.
|
|
28
|
+
COERCE = /\ACould not coerce value .* to (\S+)\z/
|
|
29
|
+
NOT_A_MEMBER = /\AExpected .* to be one of: (.*)\z/
|
|
30
|
+
NOT_NULL = /\AExpected value to not be null\z/
|
|
31
|
+
NOT_DEFINED = /\AField is not defined on (\S+)\z/
|
|
32
|
+
|
|
33
|
+
# Hasura states no input code — it stamps one code on a whole class of
|
|
34
|
+
# rejections and says what the error is about in `extensions.path`, a
|
|
35
|
+
# dotted string rather than an array. So the path is the test, not the
|
|
36
|
+
# code: only one that reaches a field's arguments is about the input.
|
|
37
|
+
# "$", "$.query" and "$.selectionSet.<field>" are the query itself — a
|
|
38
|
+
# .graphql file that doesn't parse, or names an argument the schema
|
|
39
|
+
# hasn't got, is nothing a form can highlight.
|
|
40
|
+
HASURA_CODES = %w[validation-failed parse-failed].freeze
|
|
41
|
+
# lazily, so an argument of its own named `args` doesn't win the split
|
|
42
|
+
HASURA_ARGUMENT = /\A\$\.selectionSet\..+?\.args\.(.+)\z/
|
|
43
|
+
HASURA_SEGMENT = /\A([_A-Za-z]\w*)((?:\[\d+\])*)\z/
|
|
44
|
+
|
|
45
|
+
# The explanations Hasura writes that name a kind on their own (measured
|
|
46
|
+
# against Hasura v2, one curl per entry — spec/input_errors_spec.rb
|
|
47
|
+
# holds the verbatim JSON). Its scalar family, "expected <description>
|
|
48
|
+
# for type 'T', but found <json type>", is deliberately absent: one
|
|
49
|
+
# sentence covers both `limit: -5` (out of range) and `limit: "lots"`
|
|
50
|
+
# (wrong type), and telling them apart means parsing the English
|
|
51
|
+
# description rather than reading a table.
|
|
52
|
+
HASURA_NOT_A_MEMBER = /\Aexpected one of the values \[(.*)\] for type '[^']*', but found /
|
|
53
|
+
HASURA_NOT_DEFINED = /\Afield '([^']*)' not found in type: '([^']*)'\z/
|
|
54
|
+
HASURA_NULL = /\Aunexpected null value for type '[^']*'\z/
|
|
55
|
+
QUOTED = /'([^']*)'/
|
|
56
|
+
|
|
57
|
+
# InputError::DETAILS closes the key set; this closes the types, because
|
|
58
|
+
# a right key with the wrong type under it is the same smuggling. An app
|
|
59
|
+
# is entitled to errors.rb's promise that members stays an Array —
|
|
60
|
+
# details[:members].join(", ") must not raise on what a server sent.
|
|
61
|
+
# (spec/input_errors_spec.rb holds these keys to DETAILS.)
|
|
62
|
+
DETAIL_TYPES = {
|
|
63
|
+
"type" => String, "members" => Array, "min" => Numeric,
|
|
64
|
+
"max" => Numeric, "pattern" => String, "suggestion" => String,
|
|
65
|
+
}.freeze
|
|
66
|
+
|
|
67
|
+
private_constant :VARIABLE, :COERCE, :NOT_A_MEMBER, :NOT_NULL, :NOT_DEFINED,
|
|
68
|
+
:HASURA_CODES, :HASURA_ARGUMENT, :HASURA_SEGMENT, :QUOTED,
|
|
69
|
+
:HASURA_NOT_A_MEMBER, :HASURA_NOT_DEFINED, :HASURA_NULL
|
|
70
|
+
|
|
71
|
+
class << self
|
|
72
|
+
def read(error)
|
|
73
|
+
extensions = error.extensions
|
|
74
|
+
stated = extensions["input"]
|
|
75
|
+
return [convention(error.message, stated, [], nil)] if stated.is_a?(Hash)
|
|
76
|
+
return problems(error, extensions) if extensions["problems"].is_a?(Array)
|
|
77
|
+
|
|
78
|
+
kind = GraphWeaver::GraphQLError::INPUT_CODES[error.code.to_s]
|
|
79
|
+
return [coded(error, kind)] if kind
|
|
80
|
+
|
|
81
|
+
hasura(error, extensions)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# The convention: taken verbatim, after checking `kind` is one this
|
|
87
|
+
# version knows and `details` carries only keys a kind can mean.
|
|
88
|
+
def convention(message, stated, path, value)
|
|
89
|
+
kind = stated["kind"].to_s.to_sym
|
|
90
|
+
kind = :refused unless GraphWeaver::InputError::KINDS.include?(kind)
|
|
91
|
+
coordinate = stated["coordinate"]
|
|
92
|
+
|
|
93
|
+
build(
|
|
94
|
+
message,
|
|
95
|
+
kind:,
|
|
96
|
+
path: input_path(stated["path"]) || path,
|
|
97
|
+
coordinate: (coordinate if coordinate.is_a?(String)),
|
|
98
|
+
value: stated.key?("value") ? stated["value"] : value,
|
|
99
|
+
details: details_of(stated),
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A path a server stated for the INPUT, or nil: field names and list
|
|
104
|
+
# indices, nothing else. There is no floor under it — a GraphQL error's
|
|
105
|
+
# own path names a selection ("createOrder"), and standing that in gives
|
|
106
|
+
# #field a plausible-looking name for a slot the input hasn't got.
|
|
107
|
+
def input_path(stated)
|
|
108
|
+
return unless stated.is_a?(Array)
|
|
109
|
+
|
|
110
|
+
stated if stated.all? { |segment| segment.is_a?(String) || segment.is_a?(Integer) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# the details a server stated that a kind can actually mean, both key
|
|
114
|
+
# and type — anything else is dropped rather than passed through
|
|
115
|
+
def details_of(stated)
|
|
116
|
+
DETAIL_TYPES.each_with_object({}) do |(key, type), out|
|
|
117
|
+
value = stated[key]
|
|
118
|
+
out[key.to_sym] = value if value.is_a?(type)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# One InputError per problem — a single coercion error routinely
|
|
123
|
+
# carries several, and they are about different fields.
|
|
124
|
+
def problems(error, extensions)
|
|
125
|
+
root = (match = error.message.match(VARIABLE)) ? [match[1]] : []
|
|
126
|
+
|
|
127
|
+
extensions["problems"].filter_map do |problem|
|
|
128
|
+
next unless problem.is_a?(Hash)
|
|
129
|
+
|
|
130
|
+
within = Array(problem["path"])
|
|
131
|
+
path = root + within
|
|
132
|
+
value = dig(extensions["value"], within)
|
|
133
|
+
message = problem["explanation"].to_s
|
|
134
|
+
stated = problem.dig("extensions", "input")
|
|
135
|
+
next convention(message, stated, path, value) if stated.is_a?(Hash)
|
|
136
|
+
|
|
137
|
+
explained(message, path, value, within)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def explained(message, path, value, within)
|
|
142
|
+
case message
|
|
143
|
+
when COERCE
|
|
144
|
+
# text that didn't parse, vs a thing that was never that type
|
|
145
|
+
build(message, kind: value.is_a?(String) ? :unparseable : :type_mismatch,
|
|
146
|
+
path:, value:, details: { type: $1 })
|
|
147
|
+
when NOT_A_MEMBER
|
|
148
|
+
build(message, kind: :not_a_member, path:, value:, details: { members: $1.split(", ") })
|
|
149
|
+
when NOT_NULL
|
|
150
|
+
build(message, kind: :missing, path:, value:)
|
|
151
|
+
when NOT_DEFINED
|
|
152
|
+
# the one explanation that names the input type, so the one that
|
|
153
|
+
# can give a coordinate — but only from the problem's OWN path.
|
|
154
|
+
# #path is the variable plus that, so its last segment is the
|
|
155
|
+
# variable name when the problem states none, and "RangeInput.range"
|
|
156
|
+
# is a slot the schema doesn't have.
|
|
157
|
+
type = $1
|
|
158
|
+
field = within.last
|
|
159
|
+
build(message, kind: :unknown, path:, value:,
|
|
160
|
+
coordinate: ("#{type}.#{field}" if field.is_a?(String)))
|
|
161
|
+
else
|
|
162
|
+
build(message, kind: :refused, path:, value:)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Hasura: the argument is in extensions.path or this is not about the
|
|
167
|
+
# input. No value either — Hasura never echoes back what it rejected.
|
|
168
|
+
def hasura(error, extensions)
|
|
169
|
+
return [] unless HASURA_CODES.include?(error.code.to_s)
|
|
170
|
+
|
|
171
|
+
stated = extensions["path"]
|
|
172
|
+
match = stated.is_a?(String) ? stated.match(HASURA_ARGUMENT) : nil
|
|
173
|
+
path = hasura_path(match[1]) if match
|
|
174
|
+
path ? [hasura_explained(error.message, path)] : []
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# "order_by[0].name" => ["order_by", 0, "name"]. nil rather than a
|
|
178
|
+
# partial read: a path this can't spell points a form at a field the
|
|
179
|
+
# server never named.
|
|
180
|
+
def hasura_path(stated)
|
|
181
|
+
stated.split(".").flat_map do |segment|
|
|
182
|
+
match = segment.match(HASURA_SEGMENT) or return nil
|
|
183
|
+
[match[1], *match[2].scan(/\d+/).map(&:to_i)]
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def hasura_explained(message, path)
|
|
188
|
+
case message
|
|
189
|
+
when HASURA_NOT_A_MEMBER
|
|
190
|
+
build(message, kind: :not_a_member, path:, details: { members: $1.scan(QUOTED).flatten })
|
|
191
|
+
when HASURA_NOT_DEFINED
|
|
192
|
+
build(message, kind: :unknown, path:, coordinate: "#{$2}.#{$1}")
|
|
193
|
+
when HASURA_NULL
|
|
194
|
+
# :missing is "wasn't supplied, or was null" (docs/i18n.md)
|
|
195
|
+
build(message, kind: :missing, path:)
|
|
196
|
+
else
|
|
197
|
+
build(message, kind: :refused, path:)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# A recognized validation code. `argumentName` is the input coordinate;
|
|
202
|
+
# a code that names none names no slot, and the error's own path is a
|
|
203
|
+
# QUERY path ("query", "rangeThing", …) rather than an input one.
|
|
204
|
+
def coded(error, kind)
|
|
205
|
+
extensions = error.extensions
|
|
206
|
+
argument = extensions["argumentName"]
|
|
207
|
+
# inputObjectType is stated outright; argumentNotAccepted says which
|
|
208
|
+
# kind of thing `name` is instead. A field argument has no schema
|
|
209
|
+
# coordinate here — nothing names the field's parent type.
|
|
210
|
+
type = extensions["inputObjectType"] ||
|
|
211
|
+
(extensions["name"] if extensions["typeName"] == "InputObject")
|
|
212
|
+
|
|
213
|
+
build(
|
|
214
|
+
error.message,
|
|
215
|
+
kind:,
|
|
216
|
+
path: argument.is_a?(String) ? [argument] : [],
|
|
217
|
+
coordinate: ("#{type}.#{argument}" if type.is_a?(String) && argument.is_a?(String)),
|
|
218
|
+
value: extensions["value"],
|
|
219
|
+
)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# raised: false — this is a value read off a response, and the warn
|
|
223
|
+
# line Error#initialize writes would claim a raise that never happened.
|
|
224
|
+
#
|
|
225
|
+
# The message goes through the same filter the client side puts its own
|
|
226
|
+
# messages through: a server quotes the value it rejected as a matter of
|
|
227
|
+
# course ('Could not coerce value "hunter2" to Int'), so redacting only
|
|
228
|
+
# #value would leave half the promise kept. It is capped for the same
|
|
229
|
+
# reason — the sentence is the server's, and so is its length.
|
|
230
|
+
def build(message, kind:, path:, value: nil, coordinate: nil, details: {})
|
|
231
|
+
redact = GraphWeaver::Internal::Redact
|
|
232
|
+
GraphWeaver::InputError.new(
|
|
233
|
+
redact.cap(redact.detail(path.last, message)), kind:, path:, coordinate:, details:, raised: false,
|
|
234
|
+
value: redact.value(path.last, value),
|
|
235
|
+
)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# the problem's path walked into the variable the server echoed back
|
|
239
|
+
def dig(value, path)
|
|
240
|
+
path.reduce(value) do |node, segment|
|
|
241
|
+
case node
|
|
242
|
+
when Hash then node[segment.to_s]
|
|
243
|
+
when Array then segment.is_a?(Integer) ? node[segment] : nil
|
|
244
|
+
else return nil
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module GraphWeaver
|
|
5
|
+
module Internal
|
|
6
|
+
# The client a generated module runs against while a test mode is
|
|
7
|
+
# installed — the slot `graphql: :fake` and its siblings fill, and the one
|
|
8
|
+
# a `graphql_*` helper writes to.
|
|
9
|
+
#
|
|
10
|
+
# A tag used to work by swapping GraphWeaver.client, which is the LAST
|
|
11
|
+
# place a module looks: one generated with `client:` reads its baked
|
|
12
|
+
# DEFAULT_CLIENT first and never got there, so the tag quietly didn't
|
|
13
|
+
# apply. The mode installs itself here instead, and QueryModule asks
|
|
14
|
+
# before it reads that constant — so a tag reaches every module the
|
|
15
|
+
# example runs, bound or not.
|
|
16
|
+
#
|
|
17
|
+
# Keyed by the graph a module was generated from (its baked GRAPH), since
|
|
18
|
+
# the honest answer varies: :fake for a billing module has to fabricate
|
|
19
|
+
# billing's shapes, not the other schema's. A helper names its graphs the
|
|
20
|
+
# same way and lands in the same table, so what an example says applies to
|
|
21
|
+
# the modules it runs.
|
|
22
|
+
#
|
|
23
|
+
# Test-time only. Nothing installs a mode in production, where #for is an
|
|
24
|
+
# ivar read that returns nil.
|
|
25
|
+
module TestClients
|
|
26
|
+
# The app client slot under a mode in an app with several graphs. It
|
|
27
|
+
# fills the duck-typed slot the same way every other client does, and
|
|
28
|
+
# answers the one question asked of it with the reason there is no
|
|
29
|
+
# answer — the alternative is the real endpoint, silently.
|
|
30
|
+
class NoAppClient
|
|
31
|
+
def initialize(mode) = @mode = mode
|
|
32
|
+
|
|
33
|
+
def execute(_query, **)
|
|
34
|
+
graphs = GraphWeaver.graphs
|
|
35
|
+
raise GraphWeaver::Error, "#{@mode.inspect} stands in for a graph's modules, and this " \
|
|
36
|
+
"app has #{graphs.size} graphs (#{graphs.map { |g| g.name.inspect }.join(", ")}) — so " \
|
|
37
|
+
"GraphWeaver.client has no one right answer, and this request would have gone to the " \
|
|
38
|
+
"real endpoint. A generated module runs against its own graph's stand-in; to reach one " \
|
|
39
|
+
"directly, call the client a helper returns (graphql_fake(graph: #{graphs.first.name.inspect}), " \
|
|
40
|
+
"graphql_in_process(graph: #{graphs.first.name.inspect})). Tag the example graphql: :live " \
|
|
41
|
+
"for the app's own client."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def inspect = "#<#{self.class} #{@mode.inspect}>"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
# Install `mode` for one example — the example's mode, which is what
|
|
49
|
+
# a graph no helper named runs against.
|
|
50
|
+
#
|
|
51
|
+
# Only the rspec hook installs, once per example after reset!. A
|
|
52
|
+
# helper doesn't: what a helper says is ONE graph's stand-in, written
|
|
53
|
+
# to the table by override!, so no helper can reset another graph's —
|
|
54
|
+
# which is how two graphs run in two modes in one example.
|
|
55
|
+
def install(mode)
|
|
56
|
+
@mode = mode
|
|
57
|
+
@clients = {}
|
|
58
|
+
@context = nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Back to no mode: every module resolves its own client again.
|
|
62
|
+
def reset!
|
|
63
|
+
@mode = nil
|
|
64
|
+
@clients = nil
|
|
65
|
+
@context = nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Whether an example is running under a mode — what tells suite setup
|
|
69
|
+
# apart from an example changing it out from under itself.
|
|
70
|
+
def installed? = !@mode.nil?
|
|
71
|
+
|
|
72
|
+
# Whether this example's stand-ins are already built — which is what
|
|
73
|
+
# makes a config setting they were built FROM too late to change.
|
|
74
|
+
def built? = !(@clients.nil? || @clients.empty?)
|
|
75
|
+
|
|
76
|
+
# The GraphQL context every stand-in runs with: this example's, else
|
|
77
|
+
# the suite baseline. graphql_context writes it, and the stand-ins
|
|
78
|
+
# already built take it in place — a :wire example's are built before
|
|
79
|
+
# the example body runs.
|
|
80
|
+
def context = @context || GraphWeaver::Testing.config.context
|
|
81
|
+
|
|
82
|
+
def context=(values)
|
|
83
|
+
@context = values
|
|
84
|
+
@clients&.each_value { |client| client.context = values if client.respond_to?(:context=) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# `client` stands in for every graph in `graphs`, in place of the one
|
|
88
|
+
# the mode would build. This is a helper called in an example saying
|
|
89
|
+
# what the modules it names run against.
|
|
90
|
+
def override!(client, graphs)
|
|
91
|
+
graphs.each { |graph| @clients[graph&.name] = client }
|
|
92
|
+
client
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The stand-in for `mod`, or nil when there is nothing to stand in for.
|
|
96
|
+
def for(mod)
|
|
97
|
+
return unless @mode
|
|
98
|
+
# :wire takes no client slot: it serves the resolvers at the
|
|
99
|
+
# endpoint each client already posts to — the transport you ship,
|
|
100
|
+
# running unchanged, is the whole point
|
|
101
|
+
return if @mode == :wire
|
|
102
|
+
# :live is the app's own clients, untouched — so with nothing
|
|
103
|
+
# standing in there is nothing to look up
|
|
104
|
+
return if @mode == :live && !built?
|
|
105
|
+
|
|
106
|
+
# a helper's entry wins whatever the example's mode is, and :live
|
|
107
|
+
# builds nothing of its own, so an untagged example's other graphs
|
|
108
|
+
# still resolve their own clients
|
|
109
|
+
standin(graph_for!(mod))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The stand-in `graph`'s modules run against under the installed mode,
|
|
113
|
+
# built once per example. :wire reaches it too — its clients sit
|
|
114
|
+
# behind the served endpoints rather than in the client slot, but they
|
|
115
|
+
# are the same objects graphql_context has to reach.
|
|
116
|
+
#
|
|
117
|
+
# `mode` is how a helper under a :wire tag asks for the client its own
|
|
118
|
+
# name means rather than the one :wire would have picked.
|
|
119
|
+
def standin(graph, mode = @mode)
|
|
120
|
+
@clients[graph&.name] ||= client_for(mode, graph)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The client `mode` runs `graph` against — the one answer to "what
|
|
124
|
+
# does this tag mean", asked per module here and once per example by
|
|
125
|
+
# the rspec hook. nil for the two modes that take no client slot:
|
|
126
|
+
# :live is the app's own clients, untouched, and :wire serves the
|
|
127
|
+
# resolvers at the endpoint each client already posts to.
|
|
128
|
+
def client_for(mode, graph = app_graph)
|
|
129
|
+
config = GraphWeaver::Testing.config
|
|
130
|
+
case mode
|
|
131
|
+
when :fake
|
|
132
|
+
GraphWeaver::Testing::FakeClient.new(schema: config.reference_schema!(graph),
|
|
133
|
+
registry: graph&.registry)
|
|
134
|
+
when :in_process
|
|
135
|
+
GraphWeaver::InProcess.new(config.schema_class!(graph), context:)
|
|
136
|
+
when :router
|
|
137
|
+
router = config.built_router(graph)
|
|
138
|
+
router.context = context
|
|
139
|
+
# a router is built once per supergraph, so it has to be told
|
|
140
|
+
# where this example starts — the trace, and any faked subgraph's
|
|
141
|
+
# fabricated data. Once per router, not once per graph: two
|
|
142
|
+
# graphs naming one supergraph share it, and resetting again when
|
|
143
|
+
# the second's first module resolved wiped what the first had
|
|
144
|
+
# already accumulated, mid-example.
|
|
145
|
+
router.reset! unless @clients&.value?(router)
|
|
146
|
+
router
|
|
147
|
+
when :wire
|
|
148
|
+
# what sits behind the wire is decided the way the other tags
|
|
149
|
+
# already decide it, per graph — the most faithful thing that
|
|
150
|
+
# graph has, in the order the other tags rank them
|
|
151
|
+
client_for(wire_mode(config, graph), graph)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# What :wire serves for `graph`: its router when it is in a composed
|
|
156
|
+
# supergraph, its live schema class when it has one, else a fake of
|
|
157
|
+
# its schema — which is what an app that is a pure client of someone
|
|
158
|
+
# else's API has, and the only mode it could be.
|
|
159
|
+
#
|
|
160
|
+
# The one candidate the other modes have and :wire doesn't is
|
|
161
|
+
# GraphWeaver.client's own schema: reading it introspects the very
|
|
162
|
+
# endpoint :wire is about to stub, so it is a refusal here rather than
|
|
163
|
+
# a request into a stub that doesn't exist yet.
|
|
164
|
+
def wire_mode(config, graph)
|
|
165
|
+
return :router if config.supergraph?(graph)
|
|
166
|
+
return :in_process if config.schema_class?(graph)
|
|
167
|
+
return :fake if config.schema || graph&.named_schema?
|
|
168
|
+
|
|
169
|
+
raise GraphWeaver::Error, ":wire serves your schema at the endpoint your client posts " \
|
|
170
|
+
"to, and #{graph&.name ? "graph #{graph.name.inspect}" : "this app"} has none to " \
|
|
171
|
+
"serve — no live GraphQL::Schema class, no composed supergraph, and no type " \
|
|
172
|
+
"information (nothing at #{GraphWeaver.schema_path}, and " \
|
|
173
|
+
"GraphWeaver::Testing.config.schema is unset). Your client's own schema can't stand " \
|
|
174
|
+
"in here: reading it introspects the endpoint :wire has stubbed. Commit a dump " \
|
|
175
|
+
"(rake graph_weaver:schema:refresh URL=…), or tag the example graphql: :live."
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The graph a mode builds for when no module named one: this app's
|
|
179
|
+
# only graph. With several the honest answer varies per module, so
|
|
180
|
+
# there is no app-wide one and each module resolves its own.
|
|
181
|
+
def app_graph
|
|
182
|
+
graphs = GraphWeaver.graphs
|
|
183
|
+
graphs.first if graphs.one?
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# What GraphWeaver.client holds while a mode is installed, or nil for
|
|
187
|
+
# the modes that leave the app's own there (:live, and :wire, which
|
|
188
|
+
# serves at each client's endpoint instead).
|
|
189
|
+
#
|
|
190
|
+
# One graph has one answer, so the app slot holds the same stand-in
|
|
191
|
+
# its modules resolve. With several there is none — and leaving the
|
|
192
|
+
# app's real client in the slot let a stray GraphWeaver.client.execute
|
|
193
|
+
# reach the production endpoint from an example whose tag promised no
|
|
194
|
+
# request, so the slot refuses by name instead.
|
|
195
|
+
def app_client
|
|
196
|
+
return if @mode.nil? || @mode == :live || @mode == :wire
|
|
197
|
+
|
|
198
|
+
graph = app_graph
|
|
199
|
+
graph ? standin(graph) : NoAppClient.new(@mode)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The graphs a helper stands in for: the one `graph:` names, else the
|
|
203
|
+
# ones `schema` names, else this app's only graph. A helper that
|
|
204
|
+
# reaches no module is the silent pass this slot exists to stop, so
|
|
205
|
+
# nothing to reach is a refusal — and `advice` is what THIS helper
|
|
206
|
+
# does once it knows which graph.
|
|
207
|
+
#
|
|
208
|
+
# `graph:` leads because a graph's name is its identity everywhere
|
|
209
|
+
# else in the gem, and it is the only spelling that reaches every
|
|
210
|
+
# graph: `schema` is matched by object identity, which a graph whose
|
|
211
|
+
# schema is a dump has nothing to match with.
|
|
212
|
+
def targets!(helper, schema, advice, graph: nil)
|
|
213
|
+
return [graph!(helper, graph)] if graph
|
|
214
|
+
|
|
215
|
+
named = named_graphs(schema)
|
|
216
|
+
return named if named.any?
|
|
217
|
+
|
|
218
|
+
graphs = GraphWeaver.graphs
|
|
219
|
+
return graphs if graphs.one?
|
|
220
|
+
|
|
221
|
+
raise GraphWeaver::Error, "#{helper} stands in for the modules of one graph, and " \
|
|
222
|
+
"#{schema ? "#{schema} names none of this app's graphs" : "this app has #{graphs.size}"} " \
|
|
223
|
+
"(#{declared_names}) — say which: #{helper}(graph: #{graphs.first.name.inspect}). #{advice}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
# The graph `graph:` names. Its name, not its schema: that is what
|
|
229
|
+
# `rake graph_weaver:graphs` prints, what codegen bakes into a
|
|
230
|
+
# module's GRAPH, and the one handle a dump-backed graph has.
|
|
231
|
+
def graph!(helper, name)
|
|
232
|
+
found = GraphWeaver.graphs.find { |graph| graph.name == name }
|
|
233
|
+
return found if found
|
|
234
|
+
|
|
235
|
+
near = Util.did_you_mean(GraphWeaver.graphs.map { |graph| graph.name.to_s }, name.to_s)
|
|
236
|
+
raise GraphWeaver::Error, "#{helper}(graph: #{name.inspect}) names none of this app's " \
|
|
237
|
+
"graphs (#{declared_names})#{" — did you mean #{near.to_sym.inspect}?" if near}"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The graphs `schema` names: a schema class is matched against what
|
|
241
|
+
# each graph runs in-process, which is the only thing that ties a
|
|
242
|
+
# class to a graph. A graph named by a dump can't be named this way,
|
|
243
|
+
# and correctly isn't.
|
|
244
|
+
def named_graphs(schema)
|
|
245
|
+
return [] unless schema
|
|
246
|
+
|
|
247
|
+
GraphWeaver.graphs.select { |graph| graph.live_schema.equal?(schema) }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def declared_names = GraphWeaver.graphs.map { |graph| graph.name.inspect }.join(", ")
|
|
251
|
+
|
|
252
|
+
# The graph `mod` was generated from, by the name codegen baked in.
|
|
253
|
+
# An app with several graphs and a module that names none was
|
|
254
|
+
# generated before its graph was declared, or by an older release —
|
|
255
|
+
# and guessing would fake one schema's shapes at another's module.
|
|
256
|
+
def graph_for!(mod)
|
|
257
|
+
graphs = GraphWeaver.graphs
|
|
258
|
+
return graphs.first if graphs.one?
|
|
259
|
+
|
|
260
|
+
name = mod.const_defined?(:GRAPH, false) ? mod.const_get(:GRAPH) : nil
|
|
261
|
+
found = graphs.find { |graph| graph.name == name }
|
|
262
|
+
return found if found
|
|
263
|
+
|
|
264
|
+
# Two doors produce a module, so the fix has two spellings: a file
|
|
265
|
+
# gets its GRAPH back by being regenerated, and a GraphWeaver.parse
|
|
266
|
+
# module — which generates no file, so regenerating cannot reach it
|
|
267
|
+
# — is told where it is parsed.
|
|
268
|
+
raise GraphWeaver::Error, "#{mod} doesn't say which of this app's graphs " \
|
|
269
|
+
"(#{declared_names}) it was generated from, so #{@mode.inspect} has nothing to run " \
|
|
270
|
+
"it against — regenerate it (rake graph_weaver:generate), or, if it came from " \
|
|
271
|
+
"GraphWeaver.parse, say which there (graph: #{graphs.first.name.inspect})."
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|