graph_weaver 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +8 -0
  3. data/Gemfile.lock +153 -4
  4. data/README.md +45 -79
  5. data/docs/alternatives.md +195 -0
  6. data/docs/cassettes.md +61 -50
  7. data/docs/editors.md +32 -47
  8. data/docs/errors.md +360 -103
  9. data/docs/federation.md +692 -473
  10. data/docs/generated_modules.md +441 -314
  11. data/docs/getting_started.md +370 -194
  12. data/docs/i18n.md +171 -0
  13. data/docs/logging.md +197 -50
  14. data/docs/real_world.md +42 -27
  15. data/docs/scalars.md +307 -176
  16. data/docs/testing.md +473 -220
  17. data/docs/transports.md +224 -151
  18. data/docs/upgrading.md +258 -305
  19. data/examples/README.md +38 -0
  20. data/examples/countries.rb +39 -0
  21. data/examples/federation.rb +62 -0
  22. data/examples/github/generate.rb +20 -0
  23. data/examples/github/generated/star_mutation.rb +126 -0
  24. data/examples/github/generated/stargazers_query.rb +232 -0
  25. data/examples/github/generated/starred_query.rb +151 -0
  26. data/examples/github/queries/star.graphql +8 -0
  27. data/examples/github/queries/stargazers.graphql +22 -0
  28. data/examples/github/queries/starred.graphql +11 -0
  29. data/examples/github/run.rb +43 -0
  30. data/examples/github/setup.rb +18 -0
  31. data/examples/rick_and_morty.rb +57 -0
  32. data/graph_weaver.gemspec +19 -3
  33. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  34. data/lib/graph_weaver/client.rb +69 -11
  35. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  36. data/lib/graph_weaver/codegen/emit.rb +98 -29
  37. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  38. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  39. data/lib/graph_weaver/codegen/registry.rb +175 -0
  40. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  41. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  42. data/lib/graph_weaver/codegen.rb +406 -195
  43. data/lib/graph_weaver/coerce.rb +155 -26
  44. data/lib/graph_weaver/context_seam.rb +54 -0
  45. data/lib/graph_weaver/errors.rb +284 -46
  46. data/lib/graph_weaver/federation.rb +129 -27
  47. data/lib/graph_weaver/graph.rb +315 -0
  48. data/lib/graph_weaver/hints.rb +100 -24
  49. data/lib/graph_weaver/in_process.rb +27 -15
  50. data/lib/graph_weaver/input_struct.rb +119 -32
  51. data/lib/graph_weaver/internal/endpoint.rb +80 -0
  52. data/lib/graph_weaver/internal/headers.rb +70 -0
  53. data/lib/graph_weaver/internal/overrides.rb +67 -5
  54. data/lib/graph_weaver/internal/planner.rb +45 -15
  55. data/lib/graph_weaver/internal/refusal.rb +49 -0
  56. data/lib/graph_weaver/internal/schemas.rb +23 -9
  57. data/lib/graph_weaver/internal/selection.rb +34 -0
  58. data/lib/graph_weaver/internal/server_input.rb +251 -0
  59. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  60. data/lib/graph_weaver/internal/unused.rb +287 -0
  61. data/lib/graph_weaver/internal/values.rb +40 -4
  62. data/lib/graph_weaver/internal.rb +249 -14
  63. data/lib/graph_weaver/log_subscriber.rb +74 -0
  64. data/lib/graph_weaver/logging.rb +163 -19
  65. data/lib/graph_weaver/query_module.rb +44 -3
  66. data/lib/graph_weaver/railtie.rb +237 -17
  67. data/lib/graph_weaver/representation.rb +55 -17
  68. data/lib/graph_weaver/result_struct.rb +90 -0
  69. data/lib/graph_weaver/retry.rb +45 -13
  70. data/lib/graph_weaver/rspec.rb +404 -93
  71. data/lib/graph_weaver/schema_loader.rb +266 -56
  72. data/lib/graph_weaver/tasks.rb +380 -89
  73. data/lib/graph_weaver/testing/cassette.rb +34 -10
  74. data/lib/graph_weaver/testing/endpoint.rb +107 -0
  75. data/lib/graph_weaver/testing/failure.rb +69 -12
  76. data/lib/graph_weaver/testing/fake_client.rb +164 -45
  77. data/lib/graph_weaver/testing/router.rb +64 -13
  78. data/lib/graph_weaver/testing.rb +200 -58
  79. data/lib/graph_weaver/transport/faraday.rb +41 -8
  80. data/lib/graph_weaver/transport/http.rb +48 -6
  81. data/lib/graph_weaver/transport.rb +134 -27
  82. data/lib/graph_weaver/version.rb +1 -1
  83. data/lib/graph_weaver.rb +495 -106
  84. metadata +71 -3
  85. data/CHANGELOG.md +0 -2355
@@ -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
@@ -47,7 +55,7 @@ module GraphWeaver
47
55
  nil
48
56
  end
49
57
  unless uri && %w[http https].include?(uri.scheme)
50
- 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}"
51
59
  end
52
60
 
53
61
  @uri = uri
@@ -61,7 +69,7 @@ module GraphWeaver
61
69
  # verify_mode: — so mTLS doesn't mean reaching for Faraday
62
70
  @ssl = { ca_file:, ca_path:, cert:, key:, verify_mode: }.compact
63
71
  if @ssl.any? && @uri.scheme != "https"
64
- 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)}"
65
73
  end
66
74
 
67
75
  # One permit per allowed socket: holding a permit is the right to
@@ -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,22 @@ 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,
100
+ kind: GraphWeaver::Internal::Wire.kind(query) }
54
101
 
55
102
  GraphWeaver::Internal::Log.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
56
103
  perform(query, variables, operation_name, payload)
@@ -67,8 +114,8 @@ class GraphWeaver::Transport
67
114
  # full query + variables at debug only — they can carry PII, and the
68
115
  # sensitive keys are scrubbed even there (GraphWeaver.filter_parameters)
69
116
  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)}"
117
+ filtered = GraphWeaver::Internal::Log.variables_for_log(variables)
118
+ "POST #{safe_url} #{tag} variables=#{filtered}\n#{GraphWeaver::Internal::Wire.truncate_for_log(query)}"
72
119
  end
73
120
 
74
121
  # camelCase because it's the graphql-over-http request field, not a
@@ -76,27 +123,34 @@ class GraphWeaver::Transport
76
123
  request = { query:, variables: }
77
124
  request[:operationName] = operation_name if operation_name
78
125
 
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
126
+ GraphWeaver::Internal::Wire.check_variables!(variables)
127
+ encoded = GraphWeaver::Internal::Wire.json(request)
86
128
 
87
129
  # headers is optional: a third-party subclass returning the
88
130
  # documented [status, body] pair simply has none
89
131
  status, body, headers = begin
90
- GraphWeaver::Internal::Log.log_timed(:debug, "POST #{url} #{tag} completed") do
132
+ GraphWeaver::Internal::Log.log_timed(:debug, "POST #{safe_url} #{tag} completed") do
91
133
  post(encoded)
92
134
  end
93
135
  rescue *GraphWeaver.transport_errors.to_a => e
94
- # never got a response — DNS, connection refused/reset, TLS, timeout
95
- raise GraphWeaver::TransportError, "#{e.class}: #{e.message}"
136
+ # never got a response — DNS, connection refused/reset, TLS, timeout.
137
+ # The adapter's sentence is its own words, capped like any text we
138
+ # didn't author.
139
+ raise GraphWeaver::TransportError.new(
140
+ "#{e.class}: #{GraphWeaver::Internal::Redact.cap(e.message)}", url: safe_url,
141
+ )
96
142
  end
97
143
 
98
- payload[:status] = status
99
- GraphWeaver::Internal::Log.log(:debug) { "HTTP #{status} #{tag} from #{url} (#{body.to_s.bytesize} bytes)" }
144
+ payload[:http_status] = status
145
+ # folded once: a third-party subclass's headers may come back in any
146
+ # casing, and a subclass that returns none says nothing
147
+ fields = GraphWeaver::Internal::Headers.wrap(headers || {})
148
+ # the content type, not the body: it is what tells a proxy's HTML page
149
+ # from a router's JSON without quoting bytes a server chose
150
+ GraphWeaver::Internal::Log.log(:debug) do
151
+ type = GraphWeaver::Internal::Redact.tag(fields["content-type"])
152
+ "HTTP #{status} #{tag} from #{safe_url} (#{body.to_s.bytesize} bytes#{", #{type}" if type})"
153
+ end
100
154
 
101
155
  parsed = parse_body(body)
102
156
 
@@ -109,20 +163,72 @@ class GraphWeaver::Transport
109
163
  # only a body carrying actual GraphQL errors flows through — a 4xx with
110
164
  # `"errors": null` (or []) isn't a structured error response, so the
111
165
  # status stays the signal
112
- return parsed if parsed.is_a?(Hash) && parsed["errors"].is_a?(Array) && parsed["errors"].any?
166
+ if parsed.is_a?(Hash) && parsed["errors"].is_a?(Array) && parsed["errors"].any?
167
+ return Envelope.new(parsed, status, fields.retry_after)
168
+ end
113
169
 
114
- raise GraphWeaver::ServerError.new(status:, body: body.to_s, headers: headers || {})
170
+ refuse!(status, body, headers)
115
171
  end
116
172
 
117
173
  unless parsed.is_a?(Hash)
118
174
  # 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
- )
175
+ # captive portal, or a bare JSON array/string: the server misbehaved.
176
+ # A well-formed @defer stream is named rather than lumped in: it isn't
177
+ # non-GraphQL, it's more than one GraphQL document.
178
+ detail =
179
+ if incremental?(fields)
180
+ "this response is incremental delivery (@defer/@stream), which this client doesn't read"
181
+ elsif body.to_s.empty?
182
+ "empty response body"
183
+ else
184
+ "non-GraphQL response"
185
+ end
186
+ refuse!(status, body, headers, detail:)
123
187
  end
124
188
 
125
- parsed
189
+ Envelope.new(parsed, status, fields.retry_after)
190
+ end
191
+
192
+ # The response wasn't one we can read. A body is never quoted — not into the
193
+ # message, not into a log line: it is text a server chose, and an error page
194
+ # that echoes the request fills it with the variables and the Authorization
195
+ # header we just sent. `detail` is what WE say went wrong; the bytes are on
196
+ # ServerError#body for whoever rescues it.
197
+ private def refuse!(status, body, headers, detail: nil)
198
+ raise GraphWeaver::ServerError.new(status:, body: body.to_s, headers: headers || {}, url: safe_url, detail:)
199
+ end
200
+
201
+ # The parsed envelope, plus what the HTTP response said around it — a Hash
202
+ # to everything that reads a GraphQL response, and more to the one caller
203
+ # that needs it. Retry asks both: a router answers rate limiting with a 503
204
+ # or 429 AND an errors body, so the body alone can't say whether to come
205
+ # back, and Retry-After says when. The seconds, not the headers — that is
206
+ # the whole of what Retry asks, and every other header stays where a
207
+ # ServerError already carries it.
208
+ class Envelope < Hash
209
+ attr_reader :http_status, :retry_after
210
+
211
+ def initialize(parsed, http_status, retry_after = nil)
212
+ super()
213
+ @http_status = http_status
214
+ @retry_after = retry_after
215
+ update(parsed)
216
+ end
217
+ end
218
+ private_constant :Envelope
219
+
220
+ # A leading UTF-8 BOM, which RFC 8259 §8.1 lets a parser ignore and Ruby's
221
+ # doesn't. .NET/IIS-fronted endpoints emit one, and the three bytes that
222
+ # break the parse are invisible in the body an error would quote back.
223
+ # Compared as bytes: a net/http body arrives ASCII-8BIT, a middleware's
224
+ # UTF-8, and those two are never == to each other.
225
+ BOM = "\xEF\xBB\xBF".b
226
+ private_constant :BOM
227
+
228
+ # A multipart/mixed body is one @defer/@stream response arriving in
229
+ # installments.
230
+ private def incremental?(fields)
231
+ fields["content-type"].to_s.start_with?("multipart/mixed")
126
232
  end
127
233
 
128
234
  # the parsed body, or nil when it isn't JSON (a caller's connection may
@@ -130,15 +236,16 @@ class GraphWeaver::Transport
130
236
  private def parse_body(body)
131
237
  return body unless body.is_a?(String)
132
238
 
239
+ body = T.must(body.byteslice(3..)) if body.byteslice(0, 3)&.b == BOM
133
240
  JSON.parse(body)
134
241
  rescue JSON::ParserError
135
242
  nil
136
243
  end
137
244
 
138
- # never leak Authorization headers through logs/exceptions — a
139
- # transport inspects as its class + endpoint, nothing more
245
+ # never leak credentials through logs/exceptions — a transport inspects as
246
+ # its class and the endpoint it is safe to say, nothing more
140
247
  def inspect
141
- "#<#{self.class.name} url=#{url.inspect}>"
248
+ "#<#{self.class.name} url=#{safe_url.inspect}>"
142
249
  end
143
250
  alias to_s inspect
144
251
 
@@ -1,3 +1,3 @@
1
1
  module GraphWeaver
2
- VERSION = "0.6.1"
2
+ VERSION = "0.7.1"
3
3
  end