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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +8 -0
  3. data/Gemfile.lock +153 -4
  4. data/README.md +45 -79
  5. data/docs/alternatives.md +195 -0
  6. data/docs/cassettes.md +61 -50
  7. data/docs/editors.md +32 -47
  8. data/docs/errors.md +360 -103
  9. data/docs/federation.md +692 -473
  10. data/docs/generated_modules.md +441 -314
  11. data/docs/getting_started.md +370 -194
  12. data/docs/i18n.md +171 -0
  13. data/docs/logging.md +197 -50
  14. data/docs/real_world.md +42 -27
  15. data/docs/scalars.md +307 -176
  16. data/docs/testing.md +473 -220
  17. data/docs/transports.md +224 -151
  18. data/docs/upgrading.md +258 -305
  19. data/examples/README.md +38 -0
  20. data/examples/countries.rb +39 -0
  21. data/examples/federation.rb +62 -0
  22. data/examples/github/generate.rb +20 -0
  23. data/examples/github/generated/star_mutation.rb +126 -0
  24. data/examples/github/generated/stargazers_query.rb +232 -0
  25. data/examples/github/generated/starred_query.rb +151 -0
  26. data/examples/github/queries/star.graphql +8 -0
  27. data/examples/github/queries/stargazers.graphql +22 -0
  28. data/examples/github/queries/starred.graphql +11 -0
  29. data/examples/github/run.rb +43 -0
  30. data/examples/github/setup.rb +18 -0
  31. data/examples/rick_and_morty.rb +57 -0
  32. data/graph_weaver.gemspec +19 -3
  33. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  34. data/lib/graph_weaver/client.rb +69 -11
  35. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  36. data/lib/graph_weaver/codegen/emit.rb +98 -29
  37. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  38. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  39. data/lib/graph_weaver/codegen/registry.rb +175 -0
  40. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  41. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  42. data/lib/graph_weaver/codegen.rb +406 -195
  43. data/lib/graph_weaver/coerce.rb +155 -26
  44. data/lib/graph_weaver/context_seam.rb +54 -0
  45. data/lib/graph_weaver/errors.rb +284 -46
  46. data/lib/graph_weaver/federation.rb +129 -27
  47. data/lib/graph_weaver/graph.rb +315 -0
  48. data/lib/graph_weaver/hints.rb +100 -24
  49. data/lib/graph_weaver/in_process.rb +27 -15
  50. data/lib/graph_weaver/input_struct.rb +119 -32
  51. data/lib/graph_weaver/internal/endpoint.rb +80 -0
  52. data/lib/graph_weaver/internal/headers.rb +70 -0
  53. data/lib/graph_weaver/internal/overrides.rb +67 -5
  54. data/lib/graph_weaver/internal/planner.rb +45 -15
  55. data/lib/graph_weaver/internal/refusal.rb +49 -0
  56. data/lib/graph_weaver/internal/schemas.rb +23 -9
  57. data/lib/graph_weaver/internal/selection.rb +34 -0
  58. data/lib/graph_weaver/internal/server_input.rb +251 -0
  59. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  60. data/lib/graph_weaver/internal/unused.rb +287 -0
  61. data/lib/graph_weaver/internal/values.rb +40 -4
  62. data/lib/graph_weaver/internal.rb +249 -14
  63. data/lib/graph_weaver/log_subscriber.rb +74 -0
  64. data/lib/graph_weaver/logging.rb +163 -19
  65. data/lib/graph_weaver/query_module.rb +44 -3
  66. data/lib/graph_weaver/railtie.rb +237 -17
  67. data/lib/graph_weaver/representation.rb +55 -17
  68. data/lib/graph_weaver/result_struct.rb +90 -0
  69. data/lib/graph_weaver/retry.rb +45 -13
  70. data/lib/graph_weaver/rspec.rb +404 -93
  71. data/lib/graph_weaver/schema_loader.rb +266 -56
  72. data/lib/graph_weaver/tasks.rb +380 -89
  73. data/lib/graph_weaver/testing/cassette.rb +34 -10
  74. data/lib/graph_weaver/testing/endpoint.rb +107 -0
  75. data/lib/graph_weaver/testing/failure.rb +69 -12
  76. data/lib/graph_weaver/testing/fake_client.rb +164 -45
  77. data/lib/graph_weaver/testing/router.rb +64 -13
  78. data/lib/graph_weaver/testing.rb +200 -58
  79. data/lib/graph_weaver/transport/faraday.rb +41 -8
  80. data/lib/graph_weaver/transport/http.rb +48 -6
  81. data/lib/graph_weaver/transport.rb +134 -27
  82. data/lib/graph_weaver/version.rb +1 -1
  83. data/lib/graph_weaver.rb +495 -106
  84. metadata +71 -3
  85. 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