graph_weaver 0.4.6 → 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 +1314 -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 -154
  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 -258
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -124
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +593 -334
  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 +9 -1
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +14 -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 +7 -0
  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 +794 -59
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +43 -8
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +160 -61
  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 +181 -21
  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 +28 -10
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +67 -14
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +389 -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)
@@ -67,13 +108,15 @@ class GraphWeaver::Transport
67
108
  # status stays the signal
68
109
  return parsed if parsed.is_a?(Hash) && parsed["errors"].is_a?(Array) && parsed["errors"].any?
69
110
 
70
- raise GraphWeaver::ServerError.new(status:, body: body.to_s)
111
+ raise GraphWeaver::ServerError.new(status:, body: body.to_s, headers: headers || {})
71
112
  end
72
113
 
73
114
  unless parsed.is_a?(Hash)
74
115
  # a 200 that isn't a GraphQL object — an HTML error page from a proxy, a
75
116
  # captive portal, or a bare JSON array/string: the server misbehaved
76
- raise GraphWeaver::ServerError.new(status:, body: "non-GraphQL response: #{body.to_s[0, 500]}")
117
+ raise GraphWeaver::ServerError.new(
118
+ status:, body: "non-GraphQL response: #{body.to_s[0, 500]}", headers: headers || {}
119
+ )
77
120
  end
78
121
 
79
122
  parsed
@@ -96,14 +139,21 @@ class GraphWeaver::Transport
96
139
  end
97
140
  alias to_s inspect
98
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
+
99
150
  # "[req 3 FilteredPokemon]" — a per-process request id plus the
100
- # operation name (when the document declares one)
151
+ # operation name, when there is one
101
152
  REQUEST_MUTEX = Mutex.new
102
153
 
103
- def self.log_tag(query)
154
+ def self.log_tag(operation_name = nil)
104
155
  id = REQUEST_MUTEX.synchronize { @request_count = (@request_count || 0) + 1 }
105
- name = query[/\A\s*(?:query|mutation|subscription)\s+([A-Za-z_]\w*)/, 1]
106
- "[req #{id}#{" #{name}" if name}]"
156
+ "[req #{id}#{" #{operation_name}" if operation_name}]"
107
157
  end
108
158
 
109
159
  # keep debug readable: a 100-line introspection query would drown the
@@ -117,7 +167,10 @@ class GraphWeaver::Transport
117
167
 
118
168
  private
119
169
 
120
- # POST the JSON body to the endpoint; return [status code, raw body].
121
- 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]) }
122
175
  def post(body); end
123
176
  end
@@ -1,3 +1,3 @@
1
1
  module GraphWeaver
2
- VERSION = "0.4.6"
2
+ VERSION = "0.5.0"
3
3
  end