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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1447 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +20 -6
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +497 -104
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +28 -0
  15. data/docs/scalars.md +190 -26
  16. data/docs/testing.md +457 -58
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +328 -3
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +404 -197
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +40 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +474 -106
  70. 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
- GraphWeaver.register_transport_error(Timeout::Error, OpenSSL::SSL::SSLError)
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
- @lock = Mutex.new # guards @idle and @saturated
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, DEFAULT_HEADERS.merge(@headers))
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
- # What every request sends unless the caller says otherwise.
27
- # graphql-over-http requires a conforming client to accept
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:, operation: operation_name }
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 = JSON.generate(GraphWeaver::Internal::Log.filter_variables(variables))
71
- "POST #{url} #{tag} variables=#{filtered}\n#{GraphWeaver::Internal::Wire.truncate_for_log(query)}"
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
- encoded = begin
80
- JSON.generate(request)
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 #{url} #{tag} completed") do
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, "#{e.class}: #{e.message}"
136
+ raise GraphWeaver::TransportError.new("#{e.class}: #{e.message}", url: safe_url)
96
137
  end
97
138
 
98
- payload[:status] = status
99
- GraphWeaver::Internal::Log.log(:debug) { "HTTP #{status} #{tag} from #{url} (#{body.to_s.bytesize} bytes)" }
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
- return parsed if parsed.is_a?(Hash) && parsed["errors"].is_a?(Array) && parsed["errors"].any?
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
- raise GraphWeaver::ServerError.new(
121
- status:, body: "non-GraphQL response: #{body.to_s[0, 500]}", headers: headers || {}
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 Authorization headers through logs/exceptions — a
139
- # transport inspects as its class + endpoint, nothing more
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=#{url.inspect}>"
224
+ "#<#{self.class.name} url=#{safe_url.inspect}>"
142
225
  end
143
226
  alias to_s inspect
144
227
 
@@ -1,3 +1,3 @@
1
1
  module GraphWeaver
2
- VERSION = "0.6.1"
2
+ VERSION = "0.7.0"
3
3
  end