graph_weaver 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1447 -1
- data/Gemfile +8 -0
- data/Gemfile.lock +151 -2
- data/README.md +20 -6
- data/docs/alternatives.md +201 -0
- data/docs/cassettes.md +17 -1
- data/docs/errors.md +382 -17
- data/docs/federation.md +469 -63
- data/docs/generated_modules.md +231 -15
- data/docs/getting_started.md +497 -104
- data/docs/i18n.md +234 -0
- data/docs/logging.md +160 -24
- data/docs/real_world.md +28 -0
- data/docs/scalars.md +190 -26
- data/docs/testing.md +457 -58
- data/docs/transports.md +164 -19
- data/docs/upgrading.md +328 -3
- data/graph_weaver.gemspec +7 -0
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +47 -10
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +404 -197
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/errors.rb +264 -34
- data/lib/graph_weaver/federation.rb +119 -26
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +17 -11
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +78 -0
- data/lib/graph_weaver/internal/headers.rb +51 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +40 -4
- data/lib/graph_weaver/internal.rb +183 -1
- data/lib/graph_weaver/log_subscriber.rb +66 -0
- data/lib/graph_weaver/logging.rb +136 -12
- data/lib/graph_weaver/query_module.rb +36 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +33 -5
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +221 -49
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +6 -5
- data/lib/graph_weaver/testing/endpoint.rb +106 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +133 -44
- data/lib/graph_weaver/testing/router.rb +58 -11
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +46 -4
- data/lib/graph_weaver/transport.rb +109 -26
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +474 -106
- metadata +56 -1
|
@@ -20,8 +20,16 @@ module GraphWeaver
|
|
|
20
20
|
class HTTP < Transport
|
|
21
21
|
# net/http's own network-level failures (Errno/SocketError/IOError
|
|
22
22
|
# are already seeded) — added to the shared, extensible
|
|
23
|
-
# transport-error set.
|
|
24
|
-
|
|
23
|
+
# transport-error set. A garbage status line and a mangled compressed
|
|
24
|
+
# body are as much "never got a usable response" as a reset socket is,
|
|
25
|
+
# and both recover on a fresh connection when the cause was a desynced
|
|
26
|
+
# keep-alive socket; Faraday's transport classifies them the same way.
|
|
27
|
+
GraphWeaver.register_transport_error(
|
|
28
|
+
Timeout::Error, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::ProtocolError
|
|
29
|
+
)
|
|
30
|
+
# net/http gunzips through zlib, so a build without it can't receive a
|
|
31
|
+
# compressed body in the first place
|
|
32
|
+
GraphWeaver.register_transport_error(Zlib::Error) if defined?(Zlib::Error)
|
|
25
33
|
|
|
26
34
|
# How many requests this process can have in flight at once. Rails sizes
|
|
27
35
|
# its own connection pool from RAILS_MAX_THREADS and this is the same
|
|
@@ -74,14 +82,15 @@ module GraphWeaver
|
|
|
74
82
|
# live connections, LIFO — a warm socket beats opening a cold one,
|
|
75
83
|
# so a single-threaded caller keeps reusing the same one
|
|
76
84
|
@idle = []
|
|
77
|
-
@
|
|
85
|
+
@pid = Process.pid
|
|
86
|
+
@lock = Mutex.new # guards @idle, @pid and @saturated
|
|
78
87
|
end
|
|
79
88
|
|
|
80
89
|
private
|
|
81
90
|
|
|
82
91
|
sig { override.params(body: String).returns(T::Array[T.untyped]) }
|
|
83
92
|
def post(body)
|
|
84
|
-
request = Net::HTTP::Post.new(@uri,
|
|
93
|
+
request = Net::HTTP::Post.new(@uri, request_headers)
|
|
85
94
|
request.body = body
|
|
86
95
|
|
|
87
96
|
response = with_connection { |http| http.request(request) }
|
|
@@ -90,10 +99,25 @@ module GraphWeaver
|
|
|
90
99
|
[response.code.to_i, response.body, response.each_header.to_h]
|
|
91
100
|
end
|
|
92
101
|
|
|
102
|
+
# What this request sends. A value answering #call is resolved here
|
|
103
|
+
# rather than at construction, so a header that expires — a rotating
|
|
104
|
+
# token — is asked for per request; nil drops the header, which is how
|
|
105
|
+
# an optional one says "not this time". Everything else ships as its
|
|
106
|
+
# #to_s: net/http calls #strip on the value, so the documented
|
|
107
|
+
# `-> { Current.tenant&.id }` was a bare NoMethodError in any app whose
|
|
108
|
+
# ids are Integers.
|
|
109
|
+
def request_headers
|
|
110
|
+
Transport.default_headers.merge(@headers).filter_map do |name, value|
|
|
111
|
+
value = value.call if value.respond_to?(:call)
|
|
112
|
+
[name, value.to_s] unless value.nil?
|
|
113
|
+
end.to_h
|
|
114
|
+
end
|
|
115
|
+
|
|
93
116
|
# Lease a connection for one round trip. The permit is held across
|
|
94
117
|
# the whole trip — opening the socket included — so pool_size really
|
|
95
118
|
# is the concurrency ceiling.
|
|
96
119
|
def with_connection
|
|
120
|
+
@lock.synchronize { reset_after_fork }
|
|
97
121
|
acquire_permit
|
|
98
122
|
# nothing between acquiring the permit and the ensure that returns it:
|
|
99
123
|
# an async interrupt (Rack::Timeout, a fiber cancel) landing in that
|
|
@@ -116,6 +140,24 @@ module GraphWeaver
|
|
|
116
140
|
end
|
|
117
141
|
end
|
|
118
142
|
|
|
143
|
+
# A pool belongs to the process that built it. A socket idle at fork
|
|
144
|
+
# time is inherited by every child — and nothing in a round trip says
|
|
145
|
+
# which process opened it, so workers interleave requests on one fd and
|
|
146
|
+
# a caller receives a well-formed answer to another process's query.
|
|
147
|
+
# The inherited sockets are dropped WITHOUT #finish (closing would take
|
|
148
|
+
# down the fd the parent is still using; they go with the child when it
|
|
149
|
+
# exits), and the permits are rebuilt, since any held at fork time went
|
|
150
|
+
# with the threads that held them.
|
|
151
|
+
def reset_after_fork
|
|
152
|
+
return if @pid == Process.pid
|
|
153
|
+
|
|
154
|
+
@idle.clear
|
|
155
|
+
@permits = SizedQueue.new(@pool_size)
|
|
156
|
+
@pool_size.times { @permits.push(true) }
|
|
157
|
+
@saturated = false
|
|
158
|
+
@pid = Process.pid
|
|
159
|
+
end
|
|
160
|
+
|
|
119
161
|
# A fresh persistent connection. net/http proactively reconnects
|
|
120
162
|
# when idle past keep_alive_timeout, so a server-closed keep-alive
|
|
121
163
|
# socket doesn't produce spurious failures.
|
|
@@ -6,6 +6,7 @@ require "sorbet-runtime"
|
|
|
6
6
|
|
|
7
7
|
require_relative "errors"
|
|
8
8
|
require_relative "internal"
|
|
9
|
+
require_relative "internal/endpoint"
|
|
9
10
|
require_relative "version"
|
|
10
11
|
|
|
11
12
|
# Base class for the bundled network transports — Transport::HTTP
|
|
@@ -23,8 +24,14 @@ class GraphWeaver::Transport
|
|
|
23
24
|
extend T::Helpers
|
|
24
25
|
abstract!
|
|
25
26
|
|
|
26
|
-
#
|
|
27
|
-
#
|
|
27
|
+
# Opt-in without the require: naming the constant loads the file, which is
|
|
28
|
+
# where `require "faraday"` lives — so an initializer can write
|
|
29
|
+
# Transport::Faraday.new(url) as the docs show, and an app that never names
|
|
30
|
+
# it never loads faraday. Without the gem the LoadError names it.
|
|
31
|
+
autoload :Faraday, "graph_weaver/transport/faraday"
|
|
32
|
+
|
|
33
|
+
# The fixed half of what every request sends — see .default_headers for
|
|
34
|
+
# all of it. graphql-over-http requires a conforming client to accept
|
|
28
35
|
# application/graphql-response+json; the q=0.9 fallback keeps servers
|
|
29
36
|
# that only speak the legacy media type working. The User-Agent is what
|
|
30
37
|
# lets a server operator attribute the traffic.
|
|
@@ -34,6 +41,37 @@ class GraphWeaver::Transport
|
|
|
34
41
|
"User-Agent" => "graph_weaver/#{GraphWeaver::VERSION}",
|
|
35
42
|
}.freeze
|
|
36
43
|
|
|
44
|
+
# What every request sends unless the caller says otherwise. Apollo Router
|
|
45
|
+
# and GraphOS key client attribution on the two apollographql-client-*
|
|
46
|
+
# headers — per-client SLOs, per-client rate limits, "who still asks for
|
|
47
|
+
# this deprecated field" — and a client that sends neither is attributed
|
|
48
|
+
# to the empty string along with everyone else. They are plain headers, so
|
|
49
|
+
# headers: overrides them: that is how one app names its several clients
|
|
50
|
+
# apart, which a default can't do for it.
|
|
51
|
+
def self.default_headers
|
|
52
|
+
DEFAULT_HEADERS.merge(
|
|
53
|
+
"apollographql-client-name" => client_name,
|
|
54
|
+
"apollographql-client-version" => GraphWeaver::VERSION,
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Apollo's client name is the consuming *application*, so a Rails app
|
|
59
|
+
# answers with its own name — asked per request, because Rails.application
|
|
60
|
+
# doesn't exist yet while the Gemfile is being required. The version stays
|
|
61
|
+
# the gem's: graph_weaver can't know what your app calls its releases.
|
|
62
|
+
def self.client_name
|
|
63
|
+
# const_get rather than the constant itself: an app that typechecks this
|
|
64
|
+
# gem without Rails in its sorbet payload can't resolve a bare ::Rails
|
|
65
|
+
rails = Object.const_get(:Rails) if defined?(::Rails)
|
|
66
|
+
app = rails.application if rails.respond_to?(:application)
|
|
67
|
+
return "graph_weaver" unless app
|
|
68
|
+
|
|
69
|
+
# Rails names the application class after the app: Storefront::Application
|
|
70
|
+
namespace = app.class.name.to_s.split("::")[0..-2].join("::")
|
|
71
|
+
namespace.empty? ? "graph_weaver" : namespace
|
|
72
|
+
end
|
|
73
|
+
private_class_method :client_name
|
|
74
|
+
|
|
37
75
|
# Timeouts, in seconds, shared by the bundled transports — a missing
|
|
38
76
|
# timeout is an outage, and net/http's own 60s/60s is far too patient
|
|
39
77
|
# for an API call.
|
|
@@ -44,13 +82,21 @@ class GraphWeaver::Transport
|
|
|
44
82
|
# dumps as provenance (see SchemaLoader.introspect)
|
|
45
83
|
attr_reader :url
|
|
46
84
|
|
|
85
|
+
# The same endpoint as this gem is willing to SAY it: a url can carry a
|
|
86
|
+
# credential in its userinfo or a query parameter, and a log line, an
|
|
87
|
+
# exception and an APM payload all outlive the request. Memoized, because
|
|
88
|
+
# every request says it at least twice.
|
|
89
|
+
def safe_url
|
|
90
|
+
@safe_url ||= GraphWeaver::Internal::Endpoint.safe(url)
|
|
91
|
+
end
|
|
92
|
+
|
|
47
93
|
# operation_name: names the operation to run — sent on the wire as
|
|
48
94
|
# `operationName`, which is what an APM keys its traces, rate limits and
|
|
49
95
|
# slow-query reports on. Generated modules pass their OPERATION_NAME;
|
|
50
96
|
# a raw query string falls back to the name in the document itself.
|
|
51
97
|
def execute(query, variables: {}, operation_name: nil)
|
|
52
98
|
operation_name ||= GraphWeaver::Internal::Wire.operation_name(query)
|
|
53
|
-
payload = { url
|
|
99
|
+
payload = { url: safe_url, operation: operation_name, client: self.class }
|
|
54
100
|
|
|
55
101
|
GraphWeaver::Internal::Log.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
|
|
56
102
|
perform(query, variables, operation_name, payload)
|
|
@@ -67,8 +113,8 @@ class GraphWeaver::Transport
|
|
|
67
113
|
# full query + variables at debug only — they can carry PII, and the
|
|
68
114
|
# sensitive keys are scrubbed even there (GraphWeaver.filter_parameters)
|
|
69
115
|
GraphWeaver::Internal::Log.log(:debug) do
|
|
70
|
-
filtered =
|
|
71
|
-
"POST #{
|
|
116
|
+
filtered = GraphWeaver::Internal::Log.variables_for_log(variables)
|
|
117
|
+
"POST #{safe_url} #{tag} variables=#{filtered}\n#{GraphWeaver::Internal::Wire.truncate_for_log(query)}"
|
|
72
118
|
end
|
|
73
119
|
|
|
74
120
|
# camelCase because it's the graphql-over-http request field, not a
|
|
@@ -76,27 +122,22 @@ class GraphWeaver::Transport
|
|
|
76
122
|
request = { query:, variables: }
|
|
77
123
|
request[:operationName] = operation_name if operation_name
|
|
78
124
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
rescue JSON::GeneratorError => e
|
|
82
|
-
# a value with no JSON form (NaN, Infinity, binary) — the caller's
|
|
83
|
-
# bug, surfaced under the umbrella instead of a raw JSON:: error
|
|
84
|
-
raise GraphWeaver::Error, "variables are not JSON-serializable: #{e.message}"
|
|
85
|
-
end
|
|
125
|
+
GraphWeaver::Internal::Wire.check_variables!(variables)
|
|
126
|
+
encoded = GraphWeaver::Internal::Wire.json(request)
|
|
86
127
|
|
|
87
128
|
# headers is optional: a third-party subclass returning the
|
|
88
129
|
# documented [status, body] pair simply has none
|
|
89
130
|
status, body, headers = begin
|
|
90
|
-
GraphWeaver::Internal::Log.log_timed(:debug, "POST #{
|
|
131
|
+
GraphWeaver::Internal::Log.log_timed(:debug, "POST #{safe_url} #{tag} completed") do
|
|
91
132
|
post(encoded)
|
|
92
133
|
end
|
|
93
134
|
rescue *GraphWeaver.transport_errors.to_a => e
|
|
94
135
|
# never got a response — DNS, connection refused/reset, TLS, timeout
|
|
95
|
-
raise GraphWeaver::TransportError
|
|
136
|
+
raise GraphWeaver::TransportError.new("#{e.class}: #{e.message}", url: safe_url)
|
|
96
137
|
end
|
|
97
138
|
|
|
98
|
-
payload[:
|
|
99
|
-
GraphWeaver::Internal::Log.log(:debug) { "HTTP #{status} #{tag} from #{
|
|
139
|
+
payload[:http_status] = status
|
|
140
|
+
GraphWeaver::Internal::Log.log(:debug) { "HTTP #{status} #{tag} from #{safe_url} (#{body.to_s.bytesize} bytes)" }
|
|
100
141
|
|
|
101
142
|
parsed = parse_body(body)
|
|
102
143
|
|
|
@@ -109,20 +150,61 @@ class GraphWeaver::Transport
|
|
|
109
150
|
# only a body carrying actual GraphQL errors flows through — a 4xx with
|
|
110
151
|
# `"errors": null` (or []) isn't a structured error response, so the
|
|
111
152
|
# status stays the signal
|
|
112
|
-
|
|
153
|
+
if parsed.is_a?(Hash) && parsed["errors"].is_a?(Array) && parsed["errors"].any?
|
|
154
|
+
return Envelope.new(parsed, status)
|
|
155
|
+
end
|
|
113
156
|
|
|
114
|
-
raise GraphWeaver::ServerError.new(status:, body: body.to_s, headers: headers || {})
|
|
157
|
+
raise GraphWeaver::ServerError.new(status:, body: body.to_s, headers: headers || {}, url: safe_url)
|
|
115
158
|
end
|
|
116
159
|
|
|
117
160
|
unless parsed.is_a?(Hash)
|
|
118
161
|
# a 200 that isn't a GraphQL object — an HTML error page from a proxy, a
|
|
119
|
-
# captive portal, or a bare JSON array/string: the server misbehaved
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
162
|
+
# captive portal, or a bare JSON array/string: the server misbehaved.
|
|
163
|
+
# An empty body says so rather than trailing off after the colon, and a
|
|
164
|
+
# well-formed @defer stream is named rather than dumped: it isn't
|
|
165
|
+
# non-GraphQL, it's more than one GraphQL document.
|
|
166
|
+
quoted =
|
|
167
|
+
if incremental?(headers)
|
|
168
|
+
"this response is incremental delivery (@defer/@stream), which this client doesn't read"
|
|
169
|
+
elsif body.to_s.empty?
|
|
170
|
+
"empty response body"
|
|
171
|
+
else
|
|
172
|
+
"non-GraphQL response: #{body.to_s[0, 500]}"
|
|
173
|
+
end
|
|
174
|
+
raise GraphWeaver::ServerError.new(status:, body: quoted, headers: headers || {}, url: safe_url)
|
|
123
175
|
end
|
|
124
176
|
|
|
125
|
-
parsed
|
|
177
|
+
Envelope.new(parsed, status)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The parsed envelope, plus the HTTP status it came back on — a Hash to
|
|
181
|
+
# everything that reads a GraphQL response, and to the one caller that
|
|
182
|
+
# needs more. Retry asks: a router answers rate limiting with a 503 AND
|
|
183
|
+
# an errors body, so the body alone can't say whether to come back.
|
|
184
|
+
class Envelope < Hash
|
|
185
|
+
attr_reader :http_status
|
|
186
|
+
|
|
187
|
+
def initialize(parsed, http_status)
|
|
188
|
+
super()
|
|
189
|
+
@http_status = http_status
|
|
190
|
+
update(parsed)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
private_constant :Envelope
|
|
194
|
+
|
|
195
|
+
# A leading UTF-8 BOM, which RFC 8259 §8.1 lets a parser ignore and Ruby's
|
|
196
|
+
# doesn't. .NET/IIS-fronted endpoints emit one, and the three bytes that
|
|
197
|
+
# break the parse are invisible in the body an error would quote back.
|
|
198
|
+
# Compared as bytes: a net/http body arrives ASCII-8BIT, a middleware's
|
|
199
|
+
# UTF-8, and those two are never == to each other.
|
|
200
|
+
BOM = "\xEF\xBB\xBF".b
|
|
201
|
+
private_constant :BOM
|
|
202
|
+
|
|
203
|
+
# A multipart/mixed body is one @defer/@stream response arriving in
|
|
204
|
+
# installments. Folded here because a third-party subclass's headers may
|
|
205
|
+
# come back in any casing; a subclass that returns none says nothing.
|
|
206
|
+
private def incremental?(headers)
|
|
207
|
+
GraphWeaver::Internal::Headers.wrap(headers || {})["content-type"].to_s.start_with?("multipart/mixed")
|
|
126
208
|
end
|
|
127
209
|
|
|
128
210
|
# the parsed body, or nil when it isn't JSON (a caller's connection may
|
|
@@ -130,15 +212,16 @@ class GraphWeaver::Transport
|
|
|
130
212
|
private def parse_body(body)
|
|
131
213
|
return body unless body.is_a?(String)
|
|
132
214
|
|
|
215
|
+
body = T.must(body.byteslice(3..)) if body.byteslice(0, 3)&.b == BOM
|
|
133
216
|
JSON.parse(body)
|
|
134
217
|
rescue JSON::ParserError
|
|
135
218
|
nil
|
|
136
219
|
end
|
|
137
220
|
|
|
138
|
-
# never leak
|
|
139
|
-
#
|
|
221
|
+
# never leak credentials through logs/exceptions — a transport inspects as
|
|
222
|
+
# its class and the endpoint it is safe to say, nothing more
|
|
140
223
|
def inspect
|
|
141
|
-
"#<#{self.class.name} url=#{
|
|
224
|
+
"#<#{self.class.name} url=#{safe_url.inspect}>"
|
|
142
225
|
end
|
|
143
226
|
alias to_s inspect
|
|
144
227
|
|
data/lib/graph_weaver/version.rb
CHANGED