graph_weaver 0.7.0 → 0.7.2
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.lock +4 -4
- data/README.md +40 -88
- data/docs/alternatives.md +1 -7
- data/docs/cassettes.md +54 -59
- data/docs/editors.md +32 -47
- data/docs/errors.md +261 -369
- data/docs/federation.md +650 -837
- data/docs/generated_modules.md +380 -463
- data/docs/getting_started.md +211 -428
- data/docs/i18n.md +114 -177
- data/docs/logging.md +127 -116
- data/docs/real_world.md +26 -39
- data/docs/scalars.md +277 -310
- data/docs/testing.md +343 -486
- data/docs/transports.md +203 -268
- data/docs/upgrading.md +211 -560
- 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 +12 -3
- data/lib/graph_weaver/client.rb +30 -1
- data/lib/graph_weaver/codegen/emit.rb +5 -11
- data/lib/graph_weaver/codegen.rb +23 -55
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +23 -15
- data/lib/graph_weaver/federation.rb +11 -2
- data/lib/graph_weaver/graph.rb +39 -29
- data/lib/graph_weaver/in_process.rb +15 -9
- data/lib/graph_weaver/internal/endpoint.rb +7 -5
- data/lib/graph_weaver/internal/headers.rb +19 -0
- data/lib/graph_weaver/internal/test_clients.rb +7 -11
- data/lib/graph_weaver/internal.rb +81 -13
- data/lib/graph_weaver/log_subscriber.rb +10 -2
- data/lib/graph_weaver/logging.rb +33 -13
- data/lib/graph_weaver/query_module.rb +44 -23
- data/lib/graph_weaver/retry.rb +12 -8
- data/lib/graph_weaver/rspec.rb +13 -24
- data/lib/graph_weaver/schema_loader.rb +52 -14
- data/lib/graph_weaver/tasks.rb +10 -2
- data/lib/graph_weaver/testing/cassette.rb +28 -5
- data/lib/graph_weaver/testing/endpoint.rb +14 -13
- data/lib/graph_weaver/testing/fake_client.rb +33 -3
- data/lib/graph_weaver/testing/router.rb +7 -3
- data/lib/graph_weaver/testing.rb +12 -4
- data/lib/graph_weaver/transport/http.rb +2 -2
- data/lib/graph_weaver/transport.rb +47 -23
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +32 -10
- metadata +16 -3
- data/CHANGELOG.md +0 -3801
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require "sorbet-runtime"
|
|
5
5
|
|
|
6
|
+
require_relative "internal"
|
|
6
7
|
require_relative "internal/test_clients"
|
|
7
8
|
|
|
8
9
|
module GraphWeaver
|
|
@@ -11,21 +12,21 @@ module GraphWeaver
|
|
|
11
12
|
# Runtime for generated query modules: the client plumbing, which is the
|
|
12
13
|
# one part of a generated module that carries no per-query type
|
|
13
14
|
# information — every module's copy was identical. `extend
|
|
14
|
-
# GraphWeaver::QueryModule` supplies `client
|
|
15
|
-
#
|
|
16
|
-
#
|
|
15
|
+
# GraphWeaver::QueryModule` supplies `client`; execute and from_response
|
|
16
|
+
# stay generated, since their sigs are the query's types and those are the
|
|
17
|
+
# point.
|
|
17
18
|
#
|
|
18
|
-
# Resolution order, per the docs: per call →
|
|
19
|
-
#
|
|
20
|
-
#
|
|
19
|
+
# Resolution order, per the docs: per call → a test mode's stand-in
|
|
20
|
+
# (Internal::TestClients) → the client the module's graph names →
|
|
21
|
+
# `GraphWeaver.client`. A module has no fifth slot you can set: a parsed
|
|
22
|
+
# module runs against whatever parsed it (GraphWeaver.parse(client:)),
|
|
23
|
+
# which is a property of parsing rather than a per-module override.
|
|
21
24
|
module QueryModule
|
|
22
25
|
extend T::Sig
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
# the default client (a GraphWeaver::Client or any transport) for
|
|
28
|
-
# execute: per-module override, else the baked default, else the app one
|
|
27
|
+
# What this module would execute through, right now — the client a parse
|
|
28
|
+
# bound it to, else the order above. A diagnostic, and what `execute`
|
|
29
|
+
# reads when the call names none.
|
|
29
30
|
sig { returns(T.untyped) }
|
|
30
31
|
def client
|
|
31
32
|
@client || default_client
|
|
@@ -33,6 +34,13 @@ module GraphWeaver
|
|
|
33
34
|
|
|
34
35
|
private
|
|
35
36
|
|
|
37
|
+
# Bound by GraphWeaver.parse, which is the only caller: a parsed module
|
|
38
|
+
# generates no file, so it has no graph to read a client off. Private
|
|
39
|
+
# because a generated module's client comes from its graph — one way to
|
|
40
|
+
# say a thing.
|
|
41
|
+
sig { params(client: T.untyped).void }
|
|
42
|
+
attr_writer :client
|
|
43
|
+
|
|
36
44
|
# The one call a generated `execute` makes: resolve the client, run this
|
|
37
45
|
# module's own operation, hand the raw response back for from_response to
|
|
38
46
|
# wrap. Here rather than emitted, so what has to BRACKET a request — the
|
|
@@ -44,12 +52,17 @@ module GraphWeaver
|
|
|
44
52
|
# the whole of the call instead of three arguments' worth of it.
|
|
45
53
|
sig { params(variables: T::Hash[String, T.untyped], client: T.untyped).returns(T.untyped) }
|
|
46
54
|
def dispatch(variables, client:)
|
|
55
|
+
# A value with no JSON form is a bug in the call, not in the client that
|
|
56
|
+
# would have carried it — so it is refused here, where every mode passes,
|
|
57
|
+
# rather than in the transport, which :in_process and :fake never reach.
|
|
58
|
+
# (A transport asks the same question of a raw query string, which never
|
|
59
|
+
# comes through here.)
|
|
60
|
+
GraphWeaver::Internal::Wire.check_variables!(variables)
|
|
61
|
+
|
|
47
62
|
mod = T.unsafe(self)
|
|
48
63
|
# the graph codegen baked in, never one inferred from the client — a
|
|
49
64
|
# wrong label on a request is worse than no label
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
GraphWeaver::Internal::Log.with_graph(graph) do
|
|
65
|
+
GraphWeaver::Internal::Log.with_graph(graph_name) do
|
|
53
66
|
client_for(client).execute(mod.const_get(:QUERY), variables:,
|
|
54
67
|
operation_name: mod.const_get(:OPERATION_NAME))
|
|
55
68
|
end
|
|
@@ -71,20 +84,28 @@ module GraphWeaver
|
|
|
71
84
|
"#{self}: client must respond to #execute(query, variables:), got #{target.class}"
|
|
72
85
|
end
|
|
73
86
|
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
87
|
+
# A module knows which graph it belongs to, and the graph knows how to
|
|
88
|
+
# reach it: the client that graph names, else the app default. Read at
|
|
89
|
+
# call time, so renaming the constant a graph names is an initializer
|
|
90
|
+
# edit rather than a regeneration of every module.
|
|
77
91
|
#
|
|
78
|
-
# A test mode stands in
|
|
79
|
-
# `graphql:` tag means to replace, so a
|
|
80
|
-
#
|
|
92
|
+
# A test mode stands in ahead of it: the graph's client is exactly what a
|
|
93
|
+
# `graphql:` tag means to replace, so a tagged example reaches a module
|
|
94
|
+
# whose graph names a client like every other one.
|
|
81
95
|
sig { returns(T.untyped) }
|
|
82
96
|
def default_client
|
|
83
|
-
|
|
84
|
-
stand_in = GraphWeaver::Internal::TestClients.for(mod)
|
|
97
|
+
stand_in = GraphWeaver::Internal::TestClients.for(T.unsafe(self))
|
|
85
98
|
return stand_in if stand_in
|
|
86
99
|
|
|
87
|
-
|
|
100
|
+
GraphWeaver::Internal::Util.graph_named(graph_name)&.client || GraphWeaver.client!
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The graph codegen baked in, by name — nil for a module generated before
|
|
104
|
+
# graphs existed, or by a GraphWeaver.parse that named none.
|
|
105
|
+
sig { returns(T.untyped) }
|
|
106
|
+
def graph_name
|
|
107
|
+
mod = T.unsafe(self)
|
|
108
|
+
mod.const_defined?(:GRAPH, false) ? mod.const_get(:GRAPH) : nil
|
|
88
109
|
end
|
|
89
110
|
end
|
|
90
111
|
end
|
data/lib/graph_weaver/retry.rb
CHANGED
|
@@ -38,7 +38,8 @@ require_relative "transport"
|
|
|
38
38
|
# default — pass the codes your API uses for transient failures)
|
|
39
39
|
#
|
|
40
40
|
# A server that answers with Retry-After sets the delay itself (clamped
|
|
41
|
-
# to max_delay:)
|
|
41
|
+
# to max_delay:) — whether that answer raised or came back as a response
|
|
42
|
+
# carrying GraphQL errors; otherwise the configured backoff decides.
|
|
42
43
|
#
|
|
43
44
|
# Exhausting the retries re-raises the last error (or returns the last
|
|
44
45
|
# code-matched response).
|
|
@@ -105,10 +106,10 @@ class GraphWeaver::Retry
|
|
|
105
106
|
def execute(query, variables: {}, operation_name: nil)
|
|
106
107
|
attempts = mutation?(query) ? 1 : @retries + 1
|
|
107
108
|
attempt = 0
|
|
108
|
-
failure = T.let(nil, T.nilable(Exception))
|
|
109
109
|
|
|
110
110
|
loop do
|
|
111
111
|
attempt += 1
|
|
112
|
+
failure = nil
|
|
112
113
|
begin
|
|
113
114
|
# each attempt is its own EXECUTE_EVENT; :retries says which one,
|
|
114
115
|
# so "slow" and "slow after two 502s" don't read the same in an APM
|
|
@@ -116,6 +117,8 @@ class GraphWeaver::Retry
|
|
|
116
117
|
@client.execute(query, variables:, operation_name:)
|
|
117
118
|
end
|
|
118
119
|
return response unless attempt < attempts && retryable_response?(response)
|
|
120
|
+
|
|
121
|
+
failure = response
|
|
119
122
|
rescue *@retry_on => e
|
|
120
123
|
if attempt >= attempts || !@retry_if.call(e)
|
|
121
124
|
GraphWeaver::Internal::Log.log(:warn) { MUTATION_HINT } if attempts == 1 && @retries.positive?
|
|
@@ -132,7 +135,6 @@ class GraphWeaver::Retry
|
|
|
132
135
|
"retrying #{operation_name || "query"} in #{seconds.round(2)}s (attempt #{attempt + 1} of #{attempts})"
|
|
133
136
|
end
|
|
134
137
|
@sleeper.call(seconds)
|
|
135
|
-
failure = nil
|
|
136
138
|
end
|
|
137
139
|
end
|
|
138
140
|
|
|
@@ -163,11 +165,13 @@ class GraphWeaver::Retry
|
|
|
163
165
|
end
|
|
164
166
|
|
|
165
167
|
def delay(attempt, failure)
|
|
166
|
-
# A Retry-After wins over our backoff
|
|
167
|
-
#
|
|
168
|
-
#
|
|
169
|
-
#
|
|
170
|
-
|
|
168
|
+
# A Retry-After wins over our backoff, however the failure arrived — a
|
|
169
|
+
# raised ServerError, or the envelope a rate limit that came back with an
|
|
170
|
+
# errors body makes. The server is the only party that knows when its
|
|
171
|
+
# window reopens, and it isn't guessing. Still clamped to max_delay:, so
|
|
172
|
+
# "come back in an hour" can't park a thread for an hour — and not
|
|
173
|
+
# jittered, since it's an instruction, not a guess.
|
|
174
|
+
after = failure.retry_after if failure.respond_to?(:retry_after)
|
|
171
175
|
return [after, @max_delay].min.to_f if after
|
|
172
176
|
|
|
173
177
|
# floored at 0: a custom backoff: is the caller's arithmetic, and a
|
data/lib/graph_weaver/rspec.rb
CHANGED
|
@@ -255,13 +255,12 @@ module GraphWeaver
|
|
|
255
255
|
def self.unserve!(stub) = WebMock::StubRegistry.instance.request_stubs.delete(stub)
|
|
256
256
|
|
|
257
257
|
# Every endpoint an example's modules can post to, each with the graph
|
|
258
|
-
# whose resolvers belong behind it: the client each graph
|
|
259
|
-
#
|
|
260
|
-
#
|
|
261
|
-
# at all.
|
|
258
|
+
# whose resolvers belong behind it: the client each graph names, or
|
|
259
|
+
# GraphWeaver.client for a graph naming none. One graph per endpoint —
|
|
260
|
+
# an app whose graphs all name clients needs no app default at all.
|
|
262
261
|
def self.wire_targets
|
|
263
262
|
targets = GraphWeaver.graphs.filter_map do |graph|
|
|
264
|
-
client =
|
|
263
|
+
client = graph.client || GraphWeaver.client
|
|
265
264
|
[endpoint!(client, graph), graph] if client
|
|
266
265
|
end
|
|
267
266
|
refuse_shared_endpoint!(targets)
|
|
@@ -283,24 +282,10 @@ module GraphWeaver
|
|
|
283
282
|
raise GraphWeaver::Error, "#{TAG}: :wire serves one schema at each endpoint, and graphs " \
|
|
284
283
|
"#{names} post to the same one (#{url}) — whichever were served there would answer the " \
|
|
285
284
|
"others' queries, as fields its schema doesn't define. Give each graph a client of its " \
|
|
286
|
-
"own (client
|
|
285
|
+
"own (`client` in the graph block), or tag the example #{TAG}: :in_process or " \
|
|
287
286
|
"#{TAG}: :router, which run above the wire."
|
|
288
287
|
end
|
|
289
288
|
|
|
290
|
-
# The client a graph's generated modules call. `client:` holds a
|
|
291
|
-
# constant or its name — codegen writes it into source — so a name is
|
|
292
|
-
# resolved here the way the generated DEFAULT_CLIENT lambda resolves it.
|
|
293
|
-
def self.baked_client(graph)
|
|
294
|
-
named = graph.client
|
|
295
|
-
return named unless named.is_a?(String)
|
|
296
|
-
|
|
297
|
-
Object.const_get(named)
|
|
298
|
-
rescue NameError
|
|
299
|
-
raise GraphWeaver::Error, "#{TAG}: graph #{graph.name.inspect} bakes client: " \
|
|
300
|
-
"#{named.inspect} into its modules and nothing defines that constant, so :wire can't " \
|
|
301
|
-
"find the endpoint they post to."
|
|
302
|
-
end
|
|
303
|
-
|
|
304
289
|
# The endpoint a client posts to: a transport, a Retry around one, or a
|
|
305
290
|
# Client that built one. `graph` says whose client it is, when it isn't
|
|
306
291
|
# the app's own.
|
|
@@ -316,12 +301,16 @@ module GraphWeaver
|
|
|
316
301
|
"#{TAG}: :in_process or #{TAG}: :router — they run above the wire."
|
|
317
302
|
end
|
|
318
303
|
|
|
319
|
-
# which client posts to nothing — the app's, or one graph's
|
|
304
|
+
# which client posts to nothing — the app's, or one graph's. A schema
|
|
305
|
+
# class in a client slot is named by ITS name: `client.class` is the
|
|
306
|
+
# word "Class", which names nothing anyone wrote.
|
|
320
307
|
def self.whose_client(client, graph)
|
|
321
308
|
return "GraphWeaver.client isn't set" unless client
|
|
322
|
-
return "GraphWeaver.client is #{client.class}, which posts to none" unless graph&.name
|
|
323
309
|
|
|
324
|
-
|
|
310
|
+
named = client.is_a?(Module) ? client : client.class
|
|
311
|
+
return "GraphWeaver.client is #{named}, which posts to none" unless graph&.name
|
|
312
|
+
|
|
313
|
+
"graph #{graph.name.inspect} names client #{named}, which posts to none"
|
|
325
314
|
end
|
|
326
315
|
|
|
327
316
|
def self.webmock!
|
|
@@ -353,7 +342,7 @@ module GraphWeaver
|
|
|
353
342
|
!WebMock::HttpLibAdapters::NetHttpAdapter::OriginalNetHTTP.equal?(Net::HTTP)
|
|
354
343
|
end
|
|
355
344
|
|
|
356
|
-
private_class_method :wire_targets, :refuse_shared_endpoint!, :
|
|
345
|
+
private_class_method :wire_targets, :refuse_shared_endpoint!, :whose_client,
|
|
357
346
|
:webmock!, :webmock_enabled?, :disclose!, :served, :unnamed_schemas, :loaded_schemas
|
|
358
347
|
|
|
359
348
|
# Included into every example group, so graphql_context is there
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# typed: true
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require "digest"
|
|
4
5
|
require "fileutils"
|
|
5
6
|
require "graphql"
|
|
6
7
|
require "json"
|
|
@@ -682,7 +683,7 @@ module GraphWeaver::SchemaLoader
|
|
|
682
683
|
# end
|
|
683
684
|
# schema = GraphWeaver::SchemaLoader.load(json)
|
|
684
685
|
def self.introspect(transport, cache: nil, ttl: nil, auth_env: nil)
|
|
685
|
-
cache = cache_path(cache)
|
|
686
|
+
cache = cache_path(cache, (transport.url if transport.respond_to?(:url)))
|
|
686
687
|
|
|
687
688
|
if cache
|
|
688
689
|
# reuse whatever fresh dump is present, regardless of format —
|
|
@@ -749,16 +750,20 @@ module GraphWeaver::SchemaLoader
|
|
|
749
750
|
schema
|
|
750
751
|
end
|
|
751
752
|
|
|
752
|
-
# The introspection result, asking for
|
|
753
|
+
# The introspection result, asking for the newer fields and falling back
|
|
754
|
+
# without them.
|
|
753
755
|
#
|
|
754
756
|
# isOneOf is the only thing that says an input object is @oneOf, and
|
|
755
|
-
#
|
|
756
|
-
#
|
|
757
|
-
#
|
|
758
|
-
#
|
|
759
|
-
#
|
|
757
|
+
# specifiedByURL the only thing that says what a scalar's format is;
|
|
758
|
+
# graphql-ruby leaves both out unless asked — but they are newer than
|
|
759
|
+
# plenty of servers, and one that doesn't define them REFUSES the query
|
|
760
|
+
# outright (Hasura: "field 'isOneOf' not found in type: '__Type'"). So ask,
|
|
761
|
+
# and ask the baseline query rather than give up. The second request costs
|
|
762
|
+
# one round trip on exactly the servers whose answer was going to be an
|
|
763
|
+
# error anyway.
|
|
760
764
|
def self.ask(transport)
|
|
761
|
-
|
|
765
|
+
query = GraphQL::Introspection.query(include_is_one_of: true, include_specified_by_url: true)
|
|
766
|
+
result = transport.execute(query, variables: {}).to_h
|
|
762
767
|
return result if result["errors"].nil? && result.dig("data", "__schema")
|
|
763
768
|
|
|
764
769
|
transport.execute(GraphQL::Introspection.query, variables: {}).to_h
|
|
@@ -948,23 +953,24 @@ module GraphWeaver::SchemaLoader
|
|
|
948
953
|
private_constant :CACHE_EXTENSIONS
|
|
949
954
|
|
|
950
955
|
# cache: true / :json / :graphql / :gql / a path => the file to write
|
|
951
|
-
# (nil for no caching). Symbols and true anchor at
|
|
952
|
-
# the schema dump the generation workflow reads,
|
|
953
|
-
# (introspect caches it, rake generate loads it)
|
|
954
|
-
|
|
956
|
+
# (nil for no caching). A path is taken as given. Symbols and true anchor at
|
|
957
|
+
# GraphWeaver.schema_path — the schema dump the generation workflow reads,
|
|
958
|
+
# so one file serves both (introspect caches it, rake generate loads it) —
|
|
959
|
+
# unless that dump belongs to a different endpoint; see {conventional_dump}.
|
|
960
|
+
def self.cache_path(cache, url = nil)
|
|
955
961
|
# Rails.root.join(...) hands you a Pathname, as schema: and query: already take
|
|
956
962
|
cache = cache.to_path if cache.respond_to?(:to_path)
|
|
957
963
|
path = case cache
|
|
958
964
|
when nil, false
|
|
959
965
|
nil
|
|
960
966
|
when true
|
|
961
|
-
GraphWeaver.schema_path
|
|
967
|
+
conventional_dump(File.extname(GraphWeaver.schema_path), url)
|
|
962
968
|
when Symbol
|
|
963
969
|
unless CACHE_EXTENSIONS.include?(".#{cache}")
|
|
964
970
|
raise ArgumentError, "cache: format must be :json, :graphql, or :gql, got #{cache.inspect}"
|
|
965
971
|
end
|
|
966
972
|
|
|
967
|
-
"
|
|
973
|
+
conventional_dump(".#{cache}", url)
|
|
968
974
|
else
|
|
969
975
|
unless cache.end_with?(*CACHE_EXTENSIONS)
|
|
970
976
|
raise ArgumentError, "cache: must be a .json or .graphql/.gql path, got #{cache}"
|
|
@@ -976,6 +982,38 @@ module GraphWeaver::SchemaLoader
|
|
|
976
982
|
end
|
|
977
983
|
private_class_method :cache_path
|
|
978
984
|
|
|
985
|
+
# Where an unnamed `cache:` lands. One rule: **the conventional dump, unless
|
|
986
|
+
# the dump there came from a different endpoint** — then a file of this
|
|
987
|
+
# client's own, named by a digest of its url and sitting beside it. A dump
|
|
988
|
+
# records the url it was introspected from, so this is answerable; without
|
|
989
|
+
# it, two clients at two origins both saying `cache: true` read and
|
|
990
|
+
# overwrote one file, and each was silently served the other's schema.
|
|
991
|
+
#
|
|
992
|
+
# A dump that records no url is nobody's in particular — hand-written, or
|
|
993
|
+
# committed by an older version — so it stays a hit for whoever asks.
|
|
994
|
+
def self.conventional_dump(extension, url)
|
|
995
|
+
base = strip_extension(GraphWeaver.schema_path)
|
|
996
|
+
conventional = base + extension
|
|
997
|
+
url = url && GraphWeaver::Internal::Endpoint.bare(url) # what a dump records
|
|
998
|
+
return conventional if url.nil? || !someone_elses?(conventional, url)
|
|
999
|
+
|
|
1000
|
+
"#{base}-#{Digest::SHA256.hexdigest(url)[0, 8]}#{extension}"
|
|
1001
|
+
end
|
|
1002
|
+
private_class_method :conventional_dump
|
|
1003
|
+
|
|
1004
|
+
# Whether a dump already at `path` (or a sibling format) says it came from
|
|
1005
|
+
# somewhere other than `url`. Nothing there yet is nobody's, so a cold
|
|
1006
|
+
# client still writes the conventional dump — which is what the documented
|
|
1007
|
+
# bootstrap does, and what an app with one client wants.
|
|
1008
|
+
def self.someone_elses?(path, url)
|
|
1009
|
+
existing = cache_candidates(path).find { |candidate| File.exist?(candidate) }
|
|
1010
|
+
return false unless existing
|
|
1011
|
+
|
|
1012
|
+
recorded = provenance(existing)&.dig("url")
|
|
1013
|
+
!recorded.nil? && recorded != url
|
|
1014
|
+
end
|
|
1015
|
+
private_class_method :someone_elses?
|
|
1016
|
+
|
|
979
1017
|
# the requested path first, then its siblings in the other formats
|
|
980
1018
|
def self.cache_candidates(path)
|
|
981
1019
|
path = GraphWeaver::Internal::Util.resolve(path)
|
data/lib/graph_weaver/tasks.rb
CHANGED
|
@@ -273,9 +273,17 @@ namespace :graph_weaver do
|
|
|
273
273
|
puts "#{name} #{Array(graph.queries).join(", ")} -> #{GraphWeaver::Internal::Util.relative(graph.output)}"
|
|
274
274
|
puts " namespace: #{graph.namespace}" if graph.namespace
|
|
275
275
|
# which server a graph's modules call — the one thing this task couldn't
|
|
276
|
-
# say. A graph that
|
|
276
|
+
# say. A graph that names none falls back to GraphWeaver.client, which is
|
|
277
277
|
# an app-wide setting and not this task's subject.
|
|
278
|
-
|
|
278
|
+
#
|
|
279
|
+
# Reported rather than raised: this is the task you run to find out why a
|
|
280
|
+
# graph is wrong, so a client whose constant is missing is the answer,
|
|
281
|
+
# not a reason to stop listing the others.
|
|
282
|
+
begin
|
|
283
|
+
puts " client: #{graph.client_url || graph.client}" if graph.client
|
|
284
|
+
rescue GraphWeaver::Error => e
|
|
285
|
+
puts " client: #{e.message}"
|
|
286
|
+
end
|
|
279
287
|
# a registration is scoped to one graph, and nothing else says which
|
|
280
288
|
GraphWeaver::Internal::Tasks.registrations(graph).each { |line| puts line }
|
|
281
289
|
end
|
|
@@ -115,11 +115,8 @@ module GraphWeaver
|
|
|
115
115
|
|
|
116
116
|
def initialize(path)
|
|
117
117
|
@path = Testing.cassette_path(path)
|
|
118
|
-
@entries =
|
|
118
|
+
@entries = read_entries
|
|
119
119
|
@flagged = []
|
|
120
|
-
# record is read-modify-write; two threads recording through one
|
|
121
|
-
# cassette (a parallel spec run) would each save a snapshot missing
|
|
122
|
-
# the other's entry — atomic_write keeps the file whole, not complete
|
|
123
120
|
@lock = Mutex.new
|
|
124
121
|
end
|
|
125
122
|
|
|
@@ -147,7 +144,13 @@ module GraphWeaver
|
|
|
147
144
|
entry["response"] = response
|
|
148
145
|
|
|
149
146
|
wanted = Internal::RequestKey.for(query, variables, operation_name)
|
|
150
|
-
|
|
147
|
+
# Recording rewrites the whole file, and parallel_tests points several
|
|
148
|
+
# processes at one cassette — so the read-modify-write happens under a
|
|
149
|
+
# lock every recorder shares, re-reading inside it. The snapshot taken
|
|
150
|
+
# at construction is already missing whatever another process recorded
|
|
151
|
+
# since, and saving it would throw those entries away.
|
|
152
|
+
locked do
|
|
153
|
+
@entries = read_entries
|
|
151
154
|
@entries.reject! { |existing| Internal::RequestKey.for_entry(existing) == wanted }
|
|
152
155
|
@entries << entry
|
|
153
156
|
save
|
|
@@ -198,6 +201,26 @@ module GraphWeaver
|
|
|
198
201
|
|
|
199
202
|
private
|
|
200
203
|
|
|
204
|
+
def read_entries = File.exist?(@path) ? YAML.safe_load_file(@path, aliases: true) : []
|
|
205
|
+
|
|
206
|
+
# Serialize a read-modify-write against every other recorder, in this
|
|
207
|
+
# process and any other. The Mutex is the threads; the flock is the
|
|
208
|
+
# processes. Both, because flock is held per open file, so one process's
|
|
209
|
+
# two threads would each take their own.
|
|
210
|
+
def locked
|
|
211
|
+
@lock.synchronize do
|
|
212
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
213
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
|
|
214
|
+
lock.flock(File::LOCK_EX)
|
|
215
|
+
yield
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# A sidecar, not the cassette itself: save renames a fresh file into
|
|
221
|
+
# place, so a lock held on the replaced inode guards nothing.
|
|
222
|
+
def lock_path = "#{@path}.lock"
|
|
223
|
+
|
|
201
224
|
def save
|
|
202
225
|
yaml = YAML.dump(@entries)
|
|
203
226
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
@@ -27,6 +27,12 @@ module GraphWeaver
|
|
|
27
27
|
# one thing an in-process client can't test.
|
|
28
28
|
#
|
|
29
29
|
# Router.new(supergraph:, context: ->(headers) { { current_user: User.find_by(token: headers["Authorization"]) } })
|
|
30
|
+
#
|
|
31
|
+
# Answering that proc means writing the client's context for the length
|
|
32
|
+
# of one dispatch, so the client is what resolves it: any client
|
|
33
|
+
# answering `with_request_context(headers) { }` gets the seam, which
|
|
34
|
+
# {InProcess} and {Router} take from {GraphWeaver::ContextSeam}. A client
|
|
35
|
+
# without it is served untouched, and concurrently.
|
|
30
36
|
class Endpoint
|
|
31
37
|
JSON_HEADERS = { "content-type" => "application/json" }.freeze
|
|
32
38
|
TEXT_HEADERS = { "content-type" => "text/plain" }.freeze
|
|
@@ -67,20 +73,15 @@ module GraphWeaver
|
|
|
67
73
|
|
|
68
74
|
private
|
|
69
75
|
|
|
70
|
-
# A `context:` proc is answered from the request in hand,
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return yield unless
|
|
76
|
+
# A `context:` proc is answered from the request in hand, and the
|
|
77
|
+
# client's context is what gets written to answer it — so the client
|
|
78
|
+
# does it, under its own lock. This endpoint is built per request by
|
|
79
|
+
# `graphql: :wire`; a lock held here would guard nothing the next
|
|
80
|
+
# request shares.
|
|
81
|
+
def with_context(headers, &block)
|
|
82
|
+
return yield unless @client.respond_to?(:with_request_context)
|
|
77
83
|
|
|
78
|
-
@client.
|
|
79
|
-
begin
|
|
80
|
-
yield
|
|
81
|
-
ensure
|
|
82
|
-
@client.context = context
|
|
83
|
-
end
|
|
84
|
+
@client.with_request_context(headers, &block)
|
|
84
85
|
end
|
|
85
86
|
|
|
86
87
|
# Rack spells a header HTTP_X_CALLER; the proc reads "X-Caller".
|
|
@@ -147,7 +147,11 @@ class GraphWeaver::Testing::FakeClient
|
|
|
147
147
|
# `hash` or a `count`, and a Struct answers both with plausible nonsense
|
|
148
148
|
# where fabricating is right.
|
|
149
149
|
RUBY_OWN = [BasicObject, Kernel, Object, Comparable, Enumerable, Struct, Data].freeze
|
|
150
|
-
|
|
150
|
+
|
|
151
|
+
# The scalars the GraphQL spec serializes as JSON strings, whatever Ruby
|
|
152
|
+
# holds them.
|
|
153
|
+
STRING_SCALARS = %w[ID String].freeze
|
|
154
|
+
private_constant :OPTIONS, :WIRE, :RUBY_OWN, :STRING_SCALARS
|
|
151
155
|
|
|
152
156
|
def initialize(pins = {}, **options)
|
|
153
157
|
config = GraphWeaver::Testing.config
|
|
@@ -502,13 +506,31 @@ class GraphWeaver::Testing::FakeClient
|
|
|
502
506
|
case type.kind.name
|
|
503
507
|
when "NON_NULL" then wire_value(type.of_type, value, coordinate)
|
|
504
508
|
when "LIST"
|
|
505
|
-
|
|
506
|
-
|
|
509
|
+
# whatever enumerates, not an Array alone: a has_many is an
|
|
510
|
+
# ActiveRecord CollectionProxy, and reading one straight onto the wire
|
|
511
|
+
# failed the cast as "the server sent a
|
|
512
|
+
# Order::ActiveRecord_Associations_CollectionProxy". A Hash is the one
|
|
513
|
+
# thing that enumerates and isn't a list.
|
|
514
|
+
return value if value.is_a?(Hash) || !value.is_a?(Enumerable)
|
|
515
|
+
|
|
516
|
+
value.map { |element| wire_value(type.of_type, element, coordinate) }
|
|
517
|
+
when "SCALAR" then scalar_wire(type.graphql_name, value, coordinate)
|
|
507
518
|
when "ENUM" then value.is_a?(T::Enum) ? value.serialize : value
|
|
508
519
|
else value # a composite: pinned_object reads it, one level down
|
|
509
520
|
end
|
|
510
521
|
end
|
|
511
522
|
|
|
523
|
+
# ID and String are JSON strings on every real wire, whatever Ruby type the
|
|
524
|
+
# object's column holds — an Integer primary key read straight through
|
|
525
|
+
# failed the cast with the advice for a server that sends ids unquoted,
|
|
526
|
+
# which is advice about a server that isn't there.
|
|
527
|
+
def scalar_wire(name, value, coordinate)
|
|
528
|
+
wired = @values.wire(name, value, coordinate)
|
|
529
|
+
return wired unless STRING_SCALARS.include?(name) && !wired.nil? && !wired.is_a?(String)
|
|
530
|
+
|
|
531
|
+
wired.to_s
|
|
532
|
+
end
|
|
533
|
+
|
|
512
534
|
# The concrete type a pinned object is fabricated as. At a union or
|
|
513
535
|
# interface the pin has to say: picking a member at random would fabricate
|
|
514
536
|
# a shape the pinned keys don't fit, in whichever fraction of runs the
|
|
@@ -594,12 +616,20 @@ class GraphWeaver::Testing::FakeClient
|
|
|
594
616
|
# Array.new(-1) is "negative array size" out of the fabricator's guts; a
|
|
595
617
|
# cap below zero asks for nothing, which is what a page of none is
|
|
596
618
|
return [capped, 0].max if capped.is_a?(Integer)
|
|
619
|
+
return 0 if errors_list?(node.name)
|
|
597
620
|
|
|
598
621
|
size = list_size_for(coordinate, node.name)
|
|
599
622
|
# an Integer list_size means exactly that many; a Range randomizes within it
|
|
600
623
|
size.is_a?(Range) ? rng.rand(size) : size
|
|
601
624
|
end
|
|
602
625
|
|
|
626
|
+
# A list field whose name ends in `errors` fabricates empty. The Relay and
|
|
627
|
+
# Shopify payload convention — `placeOrder { order userErrors }` — otherwise
|
|
628
|
+
# comes back with a fabricated order AND a fabricated failure, which is a
|
|
629
|
+
# response no server can send, so the natural happy-path assertion is flaky
|
|
630
|
+
# until it is pinned. Pin it to fabricate the failure path.
|
|
631
|
+
def errors_list?(name) = name.downcase.end_with?("errors")
|
|
632
|
+
|
|
603
633
|
# How long an unbounded list is. A Hash says it per list, read most
|
|
604
634
|
# specific first like a pin — which is what keeps nested lists from
|
|
605
635
|
# multiplying: every list the walk reaches re-reads this, so one number
|
|
@@ -11,6 +11,7 @@ require_relative "../internal"
|
|
|
11
11
|
require_relative "../transport"
|
|
12
12
|
require_relative "../internal/planner"
|
|
13
13
|
require_relative "../internal/subgraphs"
|
|
14
|
+
require_relative "../context_seam"
|
|
14
15
|
|
|
15
16
|
module GraphWeaver
|
|
16
17
|
module Testing
|
|
@@ -203,6 +204,8 @@ module GraphWeaver
|
|
|
203
204
|
# reset it yourself around the code path you're measuring.
|
|
204
205
|
class Router
|
|
205
206
|
include GraphWeaver::Parsing
|
|
207
|
+
# #context/#context= plus the lock over them — see the accessor below
|
|
208
|
+
include GraphWeaver::ContextSeam
|
|
206
209
|
|
|
207
210
|
# the schema the router serves — the supergraph with its composition
|
|
208
211
|
# machinery stripped, exactly what a real router exposes
|
|
@@ -232,8 +235,9 @@ module GraphWeaver
|
|
|
232
235
|
# run as a different user without rebuilding the router. A proc is
|
|
233
236
|
# answered from the request's headers, which only a wire supplies:
|
|
234
237
|
# `context: ->(headers) { { current_user: User.find_by(token:
|
|
235
|
-
# headers["Authorization"]) } }` served through {Endpoint}.
|
|
236
|
-
|
|
238
|
+
# headers["Authorization"]) } }` served through {Endpoint}. The
|
|
239
|
+
# accessors, and the lock guarding them, come from
|
|
240
|
+
# {GraphWeaver::ContextSeam}.
|
|
237
241
|
|
|
238
242
|
# The planner injects key fields under this prefix, and the concrete
|
|
239
243
|
# __typename under that key; reading an answer back means stripping
|
|
@@ -252,7 +256,7 @@ module GraphWeaver
|
|
|
252
256
|
source = supergraph.to_s # a path, or the SDL itself — Pathname included
|
|
253
257
|
@schema = GraphWeaver::SchemaLoader.load(source)
|
|
254
258
|
@table = GraphWeaver::SchemaLoader.routing_table(source)
|
|
255
|
-
|
|
259
|
+
init_context_seam(context)
|
|
256
260
|
@trace = []
|
|
257
261
|
|
|
258
262
|
Unplannable.unsupported!(@table)
|
data/lib/graph_weaver/testing.rb
CHANGED
|
@@ -221,9 +221,10 @@ module GraphWeaver
|
|
|
221
221
|
# warn line as it is constructed, and a predicate that raised to say
|
|
222
222
|
# "no" put a refusal that never happened in the log of every :wire
|
|
223
223
|
# example. The one that graph names, else config.router[:supergraph],
|
|
224
|
-
# else the conventional dump when that's what it is
|
|
225
|
-
#
|
|
226
|
-
# @join__* routing
|
|
224
|
+
# else the conventional dump when that's what it is, else the dump the
|
|
225
|
+
# app's own client was built from. A client's *schema* can't supply one
|
|
226
|
+
# — it is the API schema a router serves, with the @join__* routing
|
|
227
|
+
# table stripped out — but the file behind it carries the table.
|
|
227
228
|
private def supergraph_for(graph)
|
|
228
229
|
# named_schema?, so a graph that declared no schema of its own falls
|
|
229
230
|
# through to config.router rather than past it to the conventional dump
|
|
@@ -232,7 +233,14 @@ module GraphWeaver
|
|
|
232
233
|
return @router[:supergraph] if @router&.key?(:supergraph)
|
|
233
234
|
|
|
234
235
|
path = GraphWeaver::SchemaLoader.locate_path
|
|
235
|
-
path if path && GraphWeaver::Internal::Util.composed?(path)
|
|
236
|
+
return path if path && GraphWeaver::Internal::Util.composed?(path)
|
|
237
|
+
|
|
238
|
+
# Last, because codegen for the default graph reads the conventional
|
|
239
|
+
# dump, and the router must plan against what the modules were typed
|
|
240
|
+
# against.
|
|
241
|
+
client = GraphWeaver.client
|
|
242
|
+
source = client.schema_source if client.respond_to?(:schema_source)
|
|
243
|
+
source if source && GraphWeaver::Internal::Util.composed?(source)
|
|
236
244
|
end
|
|
237
245
|
|
|
238
246
|
# what to do about it, which differs by who asked: a graph in no
|
|
@@ -55,7 +55,7 @@ module GraphWeaver
|
|
|
55
55
|
nil
|
|
56
56
|
end
|
|
57
57
|
unless uri && %w[http https].include?(uri.scheme)
|
|
58
|
-
raise ArgumentError, "expected an http(s) url, got #{url.inspect}"
|
|
58
|
+
raise ArgumentError, "expected an http(s) url, got #{GraphWeaver::Internal::Endpoint.safe(url).inspect}"
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
@uri = uri
|
|
@@ -69,7 +69,7 @@ module GraphWeaver
|
|
|
69
69
|
# verify_mode: — so mTLS doesn't mean reaching for Faraday
|
|
70
70
|
@ssl = { ca_file:, ca_path:, cert:, key:, verify_mode: }.compact
|
|
71
71
|
if @ssl.any? && @uri.scheme != "https"
|
|
72
|
-
raise ArgumentError, "TLS options need an https url — got #{url}"
|
|
72
|
+
raise ArgumentError, "TLS options need an https url — got #{GraphWeaver::Internal::Endpoint.safe(url)}"
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
# One permit per allowed socket: holding a permit is the right to
|