graph_weaver 0.1.0 → 0.2.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +256 -0
  4. data/Gemfile.lock +32 -2
  5. data/Makefile +7 -2
  6. data/NOTES.md +1 -1
  7. data/PLAN.md +41 -1
  8. data/README.md +56 -41
  9. data/docs/cassettes.md +76 -0
  10. data/docs/errors.md +37 -9
  11. data/docs/generated_modules.md +178 -29
  12. data/docs/getting_started.md +136 -0
  13. data/docs/logging.md +33 -0
  14. data/docs/real_world.md +27 -20
  15. data/docs/scalars.md +122 -8
  16. data/docs/testing.md +55 -38
  17. data/docs/transports.md +124 -0
  18. data/graph_weaver.gemspec +7 -1
  19. data/lib/graph_weaver/client.rb +201 -0
  20. data/lib/graph_weaver/codegen/emit.rb +315 -76
  21. data/lib/graph_weaver/codegen/enum_type.rb +149 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +123 -68
  23. data/lib/graph_weaver/codegen/scalar_type.rb +53 -35
  24. data/lib/graph_weaver/codegen.rb +272 -101
  25. data/lib/graph_weaver/errors.rb +37 -25
  26. data/lib/graph_weaver/hints.rb +63 -0
  27. data/lib/graph_weaver/inflect.rb +2 -1
  28. data/lib/graph_weaver/input_struct.rb +59 -0
  29. data/lib/graph_weaver/logging.rb +41 -0
  30. data/lib/graph_weaver/railtie.rb +30 -0
  31. data/lib/graph_weaver/retry.rb +97 -0
  32. data/lib/graph_weaver/rspec.rb +17 -14
  33. data/lib/graph_weaver/schema_loader.rb +156 -20
  34. data/lib/graph_weaver/selection.rb +3 -3
  35. data/lib/graph_weaver/tasks.rb +71 -0
  36. data/lib/graph_weaver/testing/cassette.rb +42 -21
  37. data/lib/graph_weaver/testing/failure.rb +22 -22
  38. data/lib/graph_weaver/testing/{fake_executor.rb → fake_client.rb} +13 -13
  39. data/lib/graph_weaver/testing/values.rb +2 -2
  40. data/lib/graph_weaver/testing.rb +31 -15
  41. data/lib/graph_weaver/transport/faraday.rb +56 -0
  42. data/lib/graph_weaver/transport/http.rb +87 -0
  43. data/lib/graph_weaver/transport.rb +120 -0
  44. data/lib/graph_weaver/version.rb +1 -1
  45. data/lib/graph_weaver.rb +289 -38
  46. metadata +74 -5
  47. data/lib/graph_weaver/faraday_executor.rb +0 -61
  48. data/lib/graph_weaver/http_executor.rb +0 -44
  49. data/lib/graph_weaver/testing/rspec.rb +0 -5
@@ -14,29 +14,31 @@ end
14
14
  # Opt-in test tooling: require "graph_weaver/testing" from your spec
15
15
  # helper (never from production code). Configure once, initializer-style:
16
16
  #
17
- # GraphWeaver::Testing.configure do |config|
18
- # config.schema = MySchema # for auto_fake / cassettes
19
- # config.seed = 42 # reproducible fakes
20
- # config.mode = :faker # or :literal; nil = auto
21
- # config.overrides = { "Person.name" => "Daniel" }
22
- # config.list_size = 2..4
23
- # config.null_chance = 0.1 # nullable fields go nil sometimes
24
- # config.cassette_dir = "spec/cassettes"
25
- # end
17
+ # GraphWeaver::Testing.configure do |config|
18
+ # config.schema = MySchema # for auto_fake / cassettes
19
+ # config.seed = 42 # reproducible fakes
20
+ # config.mode = :faker # or :literal; nil = auto
21
+ # config.overrides = { "Person.name" => "Daniel" }
22
+ # config.list_size = 2..4
23
+ # config.null_chance = 0.1 # nullable fields go nil sometimes
24
+ # config.cassette_dir = "spec/cassettes"
25
+ # end
26
26
  #
27
27
  # mode picks how values are fabricated:
28
- # :faker — semantic, field-name matched (requires the faker gem)
29
- # :literal — plain type-derived values ("name-1", seeded numbers)
30
- # nil — auto: :faker when the gem is loaded, else :literal
28
+ # :faker — semantic, field-name matched (requires the faker gem)
29
+ # :literal — plain type-derived values ("name-1", seeded numbers)
30
+ # nil — auto: :faker when the gem is loaded, else :literal
31
31
  #
32
32
  # rspec users: require "graph_weaver/rspec" instead — it hooks the suite
33
- # (seed from rspec, optional auto-faked executor per example).
33
+ # (seed from rspec, optional auto-faked client per example).
34
34
  module GraphWeaver
35
35
  module Testing
36
36
  MODES = [:faker, :literal].freeze
37
37
 
38
38
  class Config
39
- attr_accessor :overrides, :seed, :list_size, :null_chance, :schema, :cassette_dir, :auto_fake
39
+ attr_accessor :overrides, :seed, :list_size, :null_chance, :cassette_dir, :auto_fake,
40
+ :record, :anonymize
41
+ attr_writer :schema
40
42
  attr_reader :mode
41
43
 
42
44
  def initialize
@@ -47,7 +49,21 @@ module GraphWeaver
47
49
  @mode = nil # auto
48
50
  @schema = nil
49
51
  @cassette_dir = "spec/cassettes"
52
+ # explicit opt-in: swapping every example onto a fake is too
53
+ # surprising to be a default — a little friction beats unexpected
54
+ # behavior (the schema still auto-locates once you opt in)
50
55
  @auto_fake = false
56
+ # GRAPHWEAVER_RECORD=1 rspec ... -> Cassette.use re-records
57
+ @record = !ENV["GRAPHWEAVER_RECORD"].to_s.empty?
58
+ # anonymize responses as they're recorded (needs config.schema)
59
+ @anonymize = false
60
+ end
61
+
62
+ # the explicitly configured schema, else the conventional dump
63
+ # (SchemaLoader.locate at GraphWeaver.schema_path) — nil when
64
+ # neither exists, which quietly disables auto_fake
65
+ def schema
66
+ @schema ||= GraphWeaver::SchemaLoader.locate
51
67
  end
52
68
 
53
69
  def mode=(mode)
@@ -85,6 +101,6 @@ module GraphWeaver
85
101
  end
86
102
 
87
103
  require_relative "testing/values"
88
- require_relative "testing/fake_executor"
104
+ require_relative "testing/fake_client"
89
105
  require_relative "testing/failure"
90
106
  require_relative "testing/cassette"
@@ -0,0 +1,56 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "faraday"
5
+
6
+ require_relative "../transport"
7
+
8
+ module GraphWeaver
9
+ class Transport
10
+ # Faraday-backed transport. Opt-in (faraday is not a hard dependency):
11
+ #
12
+ # require "graph_weaver/transport/faraday"
13
+ #
14
+ # # simplest: build a default connection from a url
15
+ # GraphWeaver::Transport::Faraday.new("https://api.example.com/graphql")
16
+ #
17
+ # # customize middleware while building
18
+ # GraphWeaver::Transport::Faraday.new(url) do |conn|
19
+ # conn.request :authorization, "Bearer", -> { Tokens.fetch }
20
+ # conn.response :logger
21
+ # end
22
+ #
23
+ # # or bring a fully configured connection
24
+ # GraphWeaver::Transport::Faraday.new(Faraday.new(url:) { |conn| ... })
25
+ class Faraday < Transport
26
+ # Faraday's network-level failures — added to the shared, extensible
27
+ # transport-error set.
28
+ GraphWeaver.register_transport_error(
29
+ ::Faraday::ConnectionFailed, ::Faraday::TimeoutError, ::Faraday::SSLError
30
+ )
31
+
32
+ def initialize(url_or_connection, headers: {}, &block)
33
+ @connection = case url_or_connection
34
+ when ::Faraday::Connection
35
+ url_or_connection
36
+ else
37
+ # Faraday appends the default adapter when the block doesn't set one
38
+ ::Faraday.new(url: url_or_connection, headers:, &block)
39
+ end
40
+ @url = @connection.url_prefix.to_s
41
+ end
42
+
43
+ private
44
+
45
+ sig { override.params(body: String).returns([Integer, T.untyped]) }
46
+ def post(body)
47
+ response = @connection.post do |request|
48
+ request.headers["Content-Type"] = "application/json"
49
+ request.body = body
50
+ end
51
+
52
+ [response.status, response.body]
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,87 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+
8
+ require_relative "../transport"
9
+
10
+ module GraphWeaver
11
+ class Transport
12
+ # Minimal net/http transport — zero dependencies, loaded by default:
13
+ #
14
+ # GraphWeaver::Transport::HTTP.new(url, headers: { ... }, read_timeout: 10)
15
+ #
16
+ # Timeouts surface as TransportError (retriable). The connection is
17
+ # persistent (keep-alive), serialized behind a mutex — one socket per
18
+ # transport, dropped on any failure so the next call starts fresh.
19
+ # For real connection pooling and a middleware ecosystem, use
20
+ # Transport::Faraday.
21
+ class HTTP < Transport
22
+ # net/http's own network-level failures (Errno/SocketError/IOError
23
+ # are already seeded) — added to the shared, extensible
24
+ # transport-error set.
25
+ GraphWeaver.register_transport_error(Timeout::Error, OpenSSL::SSL::SSLError)
26
+
27
+ def initialize(url, headers: {}, open_timeout: 10, read_timeout: 30, keep_alive_timeout: 2)
28
+ @url = url
29
+ @uri = URI(url)
30
+ @headers = headers
31
+ @open_timeout = open_timeout
32
+ @read_timeout = read_timeout
33
+ @keep_alive_timeout = keep_alive_timeout
34
+ @mutex = Mutex.new
35
+ @http = T.let(nil, T.nilable(Net::HTTP))
36
+ end
37
+
38
+ private
39
+
40
+ sig { override.params(body: String).returns([Integer, T.untyped]) }
41
+ def post(body)
42
+ request = Net::HTTP::Post.new(@uri, { "Content-Type" => "application/json" }.merge(@headers))
43
+ request.body = body
44
+
45
+ response = @mutex.synchronize do
46
+ begin
47
+ connection.request(request)
48
+ rescue => e
49
+ # socket state is unknown — drop it so the next call starts
50
+ # fresh (retry policy belongs to Retry, not here)
51
+ disconnect
52
+ raise e
53
+ end
54
+ end
55
+
56
+ [response.code.to_i, response.body]
57
+ end
58
+
59
+ # The persistent connection. net/http proactively reconnects when
60
+ # idle past keep_alive_timeout, so a server-closed keep-alive
61
+ # socket doesn't produce spurious failures.
62
+ def connection
63
+ @http ||= begin
64
+ GraphWeaver.log(:debug) { "connecting to #{@uri.hostname}:#{@uri.port}" }
65
+ Net::HTTP.start(
66
+ @uri.hostname, @uri.port,
67
+ use_ssl: @uri.scheme == "https",
68
+ open_timeout: @open_timeout, read_timeout: @read_timeout,
69
+ keep_alive_timeout: @keep_alive_timeout,
70
+ )
71
+ end
72
+ end
73
+
74
+ def disconnect
75
+ http = @http
76
+ return unless http
77
+
78
+ GraphWeaver.log(:debug) { "dropping connection to #{@uri.hostname}:#{@uri.port}" }
79
+ http.finish if http.started?
80
+ rescue IOError
81
+ # already closed
82
+ ensure
83
+ @http = nil
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,120 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "sorbet-runtime"
6
+
7
+ require_relative "errors"
8
+
9
+ # Base class for the bundled network transports — Transport::HTTP
10
+ # (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" => ...}
13
+ # contract as a schema class or a fake — anything in a client slot.
14
+ #
15
+ # The base class owns the shared flow — encode the request, reclassify
16
+ # network-level failures as TransportError, raise ServerError on a
17
+ # non-2xx status, parse the body — so a subclass only implements post:
18
+ # take the request body, return [status, body].
19
+ class GraphWeaver::Transport
20
+ extend T::Sig
21
+ extend T::Helpers
22
+ abstract!
23
+
24
+ # the endpoint this transport talks to — recorded into cached schema
25
+ # dumps as provenance (see SchemaLoader.introspect)
26
+ attr_reader :url
27
+
28
+ def execute(query, variables: {})
29
+ # tag pairs this request's log lines (threads interleave), and names
30
+ # the operation so the log says WHICH query, not just the url
31
+ tag = GraphWeaver.logger && GraphWeaver::Transport.log_tag(query)
32
+
33
+ # full query + variables at debug only — they can carry PII
34
+ GraphWeaver.log(:debug) do
35
+ "POST #{url} #{tag} variables=#{JSON.generate(variables)}\n#{GraphWeaver::Transport.truncate_for_log(query)}"
36
+ end
37
+
38
+ encoded = begin
39
+ JSON.generate(query:, variables:)
40
+ rescue JSON::GeneratorError => e
41
+ # a value with no JSON form (NaN, Infinity, binary) — the caller's
42
+ # bug, surfaced under the umbrella instead of a raw JSON:: error
43
+ raise GraphWeaver::Error, "variables are not JSON-serializable: #{e.message}"
44
+ end
45
+
46
+ status, body = begin
47
+ GraphWeaver.log_timed(:debug, "POST #{url} #{tag} completed") do
48
+ post(encoded)
49
+ end
50
+ rescue *GraphWeaver.transport_errors.to_a => e
51
+ # never got a response — DNS, connection refused/reset, TLS, timeout
52
+ raise GraphWeaver::TransportError, "#{e.class}: #{e.message}"
53
+ end
54
+
55
+ GraphWeaver.log(:debug) { "HTTP #{status} #{tag} from #{url} (#{body.to_s.bytesize} bytes)" }
56
+
57
+ parsed = parse_body(body)
58
+
59
+ # reached the server, but it returned a non-2xx status. Per
60
+ # graphql-over-http, routers (Apollo Server/Router) send request
61
+ # errors as 4xx WITH a GraphQL errors body — those flow into the
62
+ # envelope so QueryError machinery sees the structured errors; only
63
+ # a body that isn't GraphQL (proxy pages, HTML 500s) is a ServerError.
64
+ unless (200..299).cover?(status)
65
+ return parsed if parsed.is_a?(Hash) && parsed.key?("errors")
66
+
67
+ raise GraphWeaver::ServerError.new(status:, body: body.to_s)
68
+ end
69
+
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]}")
74
+ end
75
+
76
+ parsed
77
+ end
78
+
79
+ # the parsed body, or nil when it isn't JSON (a caller's connection may
80
+ # already parse via middleware — pass that through)
81
+ private def parse_body(body)
82
+ return body unless body.is_a?(String)
83
+
84
+ JSON.parse(body)
85
+ rescue JSON::ParserError
86
+ nil
87
+ end
88
+
89
+ # never leak Authorization headers through logs/exceptions — a
90
+ # transport inspects as its class + endpoint, nothing more
91
+ def inspect
92
+ "#<#{self.class.name} url=#{url.inspect}>"
93
+ end
94
+ alias to_s inspect
95
+
96
+ # "[req 3 FilteredPokemon]" — a per-process request id plus the
97
+ # operation name (when the document declares one)
98
+ REQUEST_MUTEX = Mutex.new
99
+
100
+ def self.log_tag(query)
101
+ 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}]"
104
+ end
105
+
106
+ # keep debug readable: a 100-line introspection query would drown the
107
+ # log — the INFO introspection line already carries the timing
108
+ LOG_QUERY_LIMIT = 600
109
+ def self.truncate_for_log(query)
110
+ return query if query.length <= LOG_QUERY_LIMIT
111
+
112
+ "#{query[0, LOG_QUERY_LIMIT]}... (truncated, #{query.bytesize} bytes total)"
113
+ end
114
+
115
+ private
116
+
117
+ # POST the JSON body to the endpoint; return [status code, raw body].
118
+ sig { abstract.params(body: String).returns([Integer, T.untyped]) }
119
+ def post(body); end
120
+ end
@@ -1,3 +1,3 @@
1
1
  module GraphWeaver
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.1"
3
3
  end