graph_weaver 0.4.4 → 0.5.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1357 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -136
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -251
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -98
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +617 -264
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +12 -6
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +21 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +15 -1
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +820 -57
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +59 -7
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +186 -62
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +194 -28
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +31 -6
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +74 -18
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +398 -170
  62. metadata +20 -3
@@ -5,11 +5,12 @@ require "json"
5
5
  require "sorbet-runtime"
6
6
 
7
7
  require_relative "errors"
8
+ require_relative "version"
8
9
 
9
10
  # Base class for the bundled network transports — Transport::HTTP
10
11
  # (zero-dependency net/http, loaded by default) and Transport::Faraday
11
- # (opt-in). A transport speaks GraphQL-over-HTTP and satisfies the
12
- # same execute(query, variables:) => {"data" => ..., "errors" => ...}
12
+ # (opt-in). A transport speaks GraphQL-over-HTTP and satisfies the same
13
+ # execute(query, variables:, operation_name:) => {"data" => ..., "errors" => ...}
13
14
  # contract as a schema class or a fake — anything in a client slot.
14
15
  #
15
16
  # The base class owns the shared flow — encode the request, reclassify
@@ -21,29 +22,68 @@ class GraphWeaver::Transport
21
22
  extend T::Helpers
22
23
  abstract!
23
24
 
25
+ # What every request sends unless the caller says otherwise.
26
+ # graphql-over-http requires a conforming client to accept
27
+ # application/graphql-response+json; the q=0.9 fallback keeps servers
28
+ # that only speak the legacy media type working. The User-Agent is what
29
+ # lets a server operator attribute the traffic.
30
+ DEFAULT_HEADERS = {
31
+ "Content-Type" => "application/json",
32
+ "Accept" => "application/graphql-response+json, application/json;q=0.9",
33
+ "User-Agent" => "graph_weaver/#{GraphWeaver::VERSION}",
34
+ }.freeze
35
+
36
+ # Timeouts, in seconds, shared by the bundled transports — a missing
37
+ # timeout is an outage, and net/http's own 60s/60s is far too patient
38
+ # for an API call.
39
+ DEFAULT_OPEN_TIMEOUT = 10
40
+ DEFAULT_READ_TIMEOUT = 30
41
+
24
42
  # the endpoint this transport talks to — recorded into cached schema
25
43
  # dumps as provenance (see SchemaLoader.introspect)
26
44
  attr_reader :url
27
45
 
28
- def execute(query, variables: {})
46
+ # operation_name: names the operation to run — sent on the wire as
47
+ # `operationName`, which is what an APM keys its traces, rate limits and
48
+ # slow-query reports on. Generated modules pass their OPERATION_NAME;
49
+ # a raw query string falls back to the name in the document itself.
50
+ def execute(query, variables: {}, operation_name: nil)
51
+ operation_name ||= GraphWeaver::Transport.operation_name(query)
52
+ payload = { url:, operation: operation_name }
53
+
54
+ GraphWeaver.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
55
+ perform(query, variables, operation_name, payload)
56
+ end
57
+ end
58
+
59
+ # The request itself. Separate from execute so the instrumenter wraps
60
+ # a call rather than a block this method returns out of.
61
+ private def perform(query, variables, operation_name, payload)
29
62
  # tag pairs this request's log lines (threads interleave), and names
30
63
  # the operation so the log says WHICH query, not just the url
31
- tag = GraphWeaver.logger && GraphWeaver::Transport.log_tag(query)
64
+ tag = GraphWeaver.logger && GraphWeaver::Transport.log_tag(operation_name)
32
65
 
33
66
  # full query + variables at debug only — they can carry PII
34
67
  GraphWeaver.log(:debug) do
35
68
  "POST #{url} #{tag} variables=#{JSON.generate(variables)}\n#{GraphWeaver::Transport.truncate_for_log(query)}"
36
69
  end
37
70
 
71
+ # camelCase because it's the graphql-over-http request field, not a
72
+ # Ruby name; omitted rather than null when the operation is anonymous
73
+ request = { query:, variables: }
74
+ request[:operationName] = operation_name if operation_name
75
+
38
76
  encoded = begin
39
- JSON.generate(query:, variables:)
77
+ JSON.generate(request)
40
78
  rescue JSON::GeneratorError => e
41
79
  # a value with no JSON form (NaN, Infinity, binary) — the caller's
42
80
  # bug, surfaced under the umbrella instead of a raw JSON:: error
43
81
  raise GraphWeaver::Error, "variables are not JSON-serializable: #{e.message}"
44
82
  end
45
83
 
46
- status, body = begin
84
+ # headers is optional: a third-party subclass returning the
85
+ # documented [status, body] pair simply has none
86
+ status, body, headers = begin
47
87
  GraphWeaver.log_timed(:debug, "POST #{url} #{tag} completed") do
48
88
  post(encoded)
49
89
  end
@@ -52,6 +92,7 @@ class GraphWeaver::Transport
52
92
  raise GraphWeaver::TransportError, "#{e.class}: #{e.message}"
53
93
  end
54
94
 
95
+ payload[:status] = status
55
96
  GraphWeaver.log(:debug) { "HTTP #{status} #{tag} from #{url} (#{body.to_s.bytesize} bytes)" }
56
97
 
57
98
  parsed = parse_body(body)
@@ -62,15 +103,20 @@ class GraphWeaver::Transport
62
103
  # envelope so QueryError machinery sees the structured errors; only
63
104
  # a body that isn't GraphQL (proxy pages, HTML 500s) is a ServerError.
64
105
  unless (200..299).cover?(status)
65
- return parsed if parsed.is_a?(Hash) && parsed.key?("errors")
106
+ # only a body carrying actual GraphQL errors flows through — a 4xx with
107
+ # `"errors": null` (or []) isn't a structured error response, so the
108
+ # status stays the signal
109
+ return parsed if parsed.is_a?(Hash) && parsed["errors"].is_a?(Array) && parsed["errors"].any?
66
110
 
67
- raise GraphWeaver::ServerError.new(status:, body: body.to_s)
111
+ raise GraphWeaver::ServerError.new(status:, body: body.to_s, headers: headers || {})
68
112
  end
69
113
 
70
- unless parsed
71
- # a 200 that isn't GraphQL — an HTML error page from a proxy, a
72
- # captive portal: the server misbehaved, classify it that way
73
- raise GraphWeaver::ServerError.new(status:, body: "non-JSON response: #{body.to_s[0, 500]}")
114
+ unless parsed.is_a?(Hash)
115
+ # a 200 that isn't a GraphQL object — an HTML error page from a proxy, a
116
+ # captive portal, or a bare JSON array/string: the server misbehaved
117
+ raise GraphWeaver::ServerError.new(
118
+ status:, body: "non-GraphQL response: #{body.to_s[0, 500]}", headers: headers || {}
119
+ )
74
120
  end
75
121
 
76
122
  parsed
@@ -93,14 +139,21 @@ class GraphWeaver::Transport
93
139
  end
94
140
  alias to_s inspect
95
141
 
142
+ # The name of the document's FIRST operation, nil when anonymous. Only
143
+ # the fallback for a raw query string handed straight to a transport —
144
+ # generated modules pass their OPERATION_NAME, parsed properly.
145
+ OPERATION_NAME_PATTERN = /\A\s*(?:query|mutation|subscription)\s+([A-Za-z_]\w*)/
146
+ def self.operation_name(query)
147
+ query[OPERATION_NAME_PATTERN, 1]
148
+ end
149
+
96
150
  # "[req 3 FilteredPokemon]" — a per-process request id plus the
97
- # operation name (when the document declares one)
151
+ # operation name, when there is one
98
152
  REQUEST_MUTEX = Mutex.new
99
153
 
100
- def self.log_tag(query)
154
+ def self.log_tag(operation_name = nil)
101
155
  id = REQUEST_MUTEX.synchronize { @request_count = (@request_count || 0) + 1 }
102
- name = query[/\A\s*(?:query|mutation|subscription)\s+([A-Za-z_]\w*)/, 1]
103
- "[req #{id}#{" #{name}" if name}]"
156
+ "[req #{id}#{" #{operation_name}" if operation_name}]"
104
157
  end
105
158
 
106
159
  # keep debug readable: a 100-line introspection query would drown the
@@ -114,7 +167,10 @@ class GraphWeaver::Transport
114
167
 
115
168
  private
116
169
 
117
- # POST the JSON body to the endpoint; return [status code, raw body].
118
- sig { abstract.params(body: String).returns([Integer, T.untyped]) }
170
+ # POST the JSON body to the endpoint; return [status code, raw body]
171
+ # optionally with a third element, the response headers as a Hash
172
+ # with downcased names, which ServerError then carries (Retry-After,
173
+ # x-ratelimit-*). Two elements remains a complete answer.
174
+ sig { abstract.params(body: String).returns(T::Array[T.untyped]) }
119
175
  def post(body); end
120
176
  end
@@ -1,3 +1,3 @@
1
1
  module GraphWeaver
2
- VERSION = "0.4.4"
2
+ VERSION = "0.5.0"
3
3
  end