graph_weaver 0.7.0 → 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.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 +370 -459
- 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 +340 -486
- data/docs/transports.md +191 -263
- data/docs/upgrading.md +188 -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 +22 -1
- data/lib/graph_weaver/codegen.rb +5 -1
- 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/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.rb +66 -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 +8 -0
- data/lib/graph_weaver/retry.rb +12 -8
- data/lib/graph_weaver/schema_loader.rb +52 -14
- 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/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 +22 -1
- metadata +16 -3
- data/CHANGELOG.md +0 -3801
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# typed: ignore — ActiveSupport::LogSubscriber, which sorbet can't resolve here
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
# The superclass, so this file stands alone: requiring it by hand is the
|
|
5
|
+
# documented way to subscribe outside Rails, and there is no railtie out there
|
|
6
|
+
# to have loaded ActiveSupport first. Skipped when the constant already
|
|
7
|
+
# exists, which is how a stand-in can take its place.
|
|
8
|
+
require "active_support/log_subscriber" unless defined?(ActiveSupport::LogSubscriber)
|
|
9
|
+
|
|
4
10
|
module GraphWeaver
|
|
5
11
|
# One line per GraphQL operation in a Rails log, the shape ActiveRecord
|
|
6
12
|
# uses for a query:
|
|
@@ -54,11 +60,13 @@ module GraphWeaver
|
|
|
54
60
|
payload[:graph] ? "#{payload[:graph]}/#{operation}" : operation
|
|
55
61
|
end
|
|
56
62
|
|
|
57
|
-
# status, then whatever narrows it: the error class, the
|
|
63
|
+
# status, then whatever narrows it: the error class, the reason an alert
|
|
58
64
|
# groups by, and which attempt this was when a Retry is in the stack.
|
|
59
65
|
def outcome(payload)
|
|
60
66
|
parts = [payload[:status], payload[:error]]
|
|
61
|
-
|
|
67
|
+
# the GraphQL code, or the HTTP status where the request never got one
|
|
68
|
+
reason = payload[:code] || (payload[:http_status] if payload[:status] == :failed)
|
|
69
|
+
parts << "[#{reason}]" if reason
|
|
62
70
|
parts << "(retry #{payload[:retries]})" if payload[:retries].to_i.positive?
|
|
63
71
|
parts.compact.join(" ")
|
|
64
72
|
end
|
data/lib/graph_weaver/logging.rb
CHANGED
|
@@ -53,7 +53,7 @@ module GraphWeaver
|
|
|
53
53
|
#
|
|
54
54
|
# It must call the block and return its value. The only event today is
|
|
55
55
|
# EXECUTE_EVENT; its payload is the contract in docs/logging.md —
|
|
56
|
-
# :operation, :client, :status, :duration_ms, :graph always;
|
|
56
|
+
# :operation, :client, :kind, :status, :duration_ms, :graph always;
|
|
57
57
|
# :url/:http_status over the wire, :schema in-process, :error/:code on a
|
|
58
58
|
# failure, :retries when a Retry wrapped it. Never the query text or the
|
|
59
59
|
# variables: the payload fans out to subscribers that know none of the
|
|
@@ -77,6 +77,15 @@ module GraphWeaver
|
|
|
77
77
|
!key.nil? && Log.filter_variables({ key.to_s => nil })[key.to_s] == FILTERED
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
# True when this key names a credential — asked of a url's query
|
|
81
|
+
# parameters, which are scrubbed whatever the app's logging appetite.
|
|
82
|
+
# filter_parameters is a knob about log verbosity; emptying it must
|
|
83
|
+
# not un-scrub a token in an endpoint, any more than it un-scrubs the
|
|
84
|
+
# url's userinfo. The app's list widens this; it can't narrow it.
|
|
85
|
+
def credential?(key)
|
|
86
|
+
filtered?(key) || Log.filtered?(key, GraphWeaver::DEFAULT_FILTER_PARAMETERS)
|
|
87
|
+
end
|
|
88
|
+
|
|
80
89
|
# `detail` unless the key is filtered — free text a coercer or sorbet
|
|
81
90
|
# wrote can spell a value any way, so for a filtered key none of it
|
|
82
91
|
# survives, not the parts that would have been safe.
|
|
@@ -94,12 +103,18 @@ module GraphWeaver
|
|
|
94
103
|
# optional because a coercer refusing a value hasn't been told one.
|
|
95
104
|
def shown(raw, key = nil) = filtered?(key) ? FILTERED : cap(value(key, raw).inspect)
|
|
96
105
|
|
|
106
|
+
# A short server-chosen string the library republishes inside its own
|
|
107
|
+
# text — the APM's :code, the [CODE] in the one line info writes, a
|
|
108
|
+
# redirect's destination. Control characters are stripped because it
|
|
109
|
+
# lands where the log's own framing lives: a newline in
|
|
110
|
+
# extensions.code forges a second, complete-looking line.
|
|
111
|
+
def tag(value) = value.is_a?(String) ? cap(value.gsub(/[[:cntrl:]]+/, " ")) : value
|
|
112
|
+
|
|
97
113
|
# Text the library didn't author — a value a caller sent, a sentence a
|
|
98
114
|
# server wrote — cut to what an error may carry. The number lives on
|
|
99
115
|
# InputError, which is the class that documents it and the one every
|
|
100
116
|
# capped string reaches.
|
|
101
|
-
def cap(text)
|
|
102
|
-
limit = GraphWeaver::InputError::VALUE_LIMIT
|
|
117
|
+
def cap(text, limit = GraphWeaver::InputError::VALUE_LIMIT)
|
|
103
118
|
return text if text.bytesize <= limit
|
|
104
119
|
|
|
105
120
|
# byteslice can land mid-character; scrub drops the partial tail
|
|
@@ -183,13 +198,15 @@ module GraphWeaver
|
|
|
183
198
|
payload[:status] = :ok
|
|
184
199
|
else
|
|
185
200
|
payload[:status] = :errors
|
|
186
|
-
|
|
201
|
+
code = errors.grep(Hash).filter_map { |e| GraphWeaver::GraphQLError.from_h(e).code }.first
|
|
202
|
+
payload[:code] = Redact.tag(code)
|
|
187
203
|
end
|
|
188
204
|
result
|
|
189
205
|
rescue => e
|
|
190
206
|
payload[:error] = e.class.name
|
|
191
|
-
#
|
|
192
|
-
|
|
207
|
+
# :code stays the GraphQL error code and nothing else — it used
|
|
208
|
+
# to hold a ServerError's status here, so one tag carried two
|
|
209
|
+
# dimensions ("THROTTLED" and 429). The number is :http_status.
|
|
193
210
|
raise
|
|
194
211
|
ensure
|
|
195
212
|
payload[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
|
|
@@ -249,6 +266,16 @@ module GraphWeaver
|
|
|
249
266
|
filters.nil? ? variables : filters.filter(variables)
|
|
250
267
|
end
|
|
251
268
|
|
|
269
|
+
# Whether a key matches one of `filters` — the same matching the
|
|
270
|
+
# variables line uses, asked about a list other than the app's so
|
|
271
|
+
# Redact.credential? can hold url parameters to the default names.
|
|
272
|
+
def filtered?(key, filters)
|
|
273
|
+
name = key.to_s
|
|
274
|
+
filters.any? do |filter|
|
|
275
|
+
filter.is_a?(Regexp) ? name.match?(filter) : name.downcase.include?(filter.to_s.downcase)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
252
279
|
private
|
|
253
280
|
|
|
254
281
|
# The GraphQL errors a response carries, whatever answered it — a
|
|
@@ -269,13 +296,6 @@ module GraphWeaver
|
|
|
269
296
|
else value
|
|
270
297
|
end
|
|
271
298
|
end
|
|
272
|
-
|
|
273
|
-
def filtered?(key, filters)
|
|
274
|
-
name = key.to_s
|
|
275
|
-
filters.any? do |filter|
|
|
276
|
-
filter.is_a?(Regexp) ? name.match?(filter) : name.downcase.include?(filter.to_s.downcase)
|
|
277
|
-
end
|
|
278
|
-
end
|
|
279
299
|
end
|
|
280
300
|
end
|
|
281
301
|
end
|
|
@@ -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
|
|
@@ -44,6 +45,13 @@ module GraphWeaver
|
|
|
44
45
|
# the whole of the call instead of three arguments' worth of it.
|
|
45
46
|
sig { params(variables: T::Hash[String, T.untyped], client: T.untyped).returns(T.untyped) }
|
|
46
47
|
def dispatch(variables, client:)
|
|
48
|
+
# A value with no JSON form is a bug in the call, not in the client that
|
|
49
|
+
# would have carried it — so it is refused here, where every mode passes,
|
|
50
|
+
# rather than in the transport, which :in_process and :fake never reach.
|
|
51
|
+
# (A transport asks the same question of a raw query string, which never
|
|
52
|
+
# comes through here.)
|
|
53
|
+
GraphWeaver::Internal::Wire.check_variables!(variables)
|
|
54
|
+
|
|
47
55
|
mod = T.unsafe(self)
|
|
48
56
|
# the graph codegen baked in, never one inferred from the client — a
|
|
49
57
|
# wrong label on a request is worse than no label
|
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
|
|
@@ -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)
|
|
@@ -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)
|
|
@@ -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
|