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.
- checksums.yaml +4 -4
- data/.yardopts +6 -0
- data/CHANGELOG.md +256 -0
- data/Gemfile.lock +32 -2
- data/Makefile +7 -2
- data/NOTES.md +1 -1
- data/PLAN.md +41 -1
- data/README.md +56 -41
- data/docs/cassettes.md +76 -0
- data/docs/errors.md +37 -9
- data/docs/generated_modules.md +178 -29
- data/docs/getting_started.md +136 -0
- data/docs/logging.md +33 -0
- data/docs/real_world.md +27 -20
- data/docs/scalars.md +122 -8
- data/docs/testing.md +55 -38
- data/docs/transports.md +124 -0
- data/graph_weaver.gemspec +7 -1
- data/lib/graph_weaver/client.rb +201 -0
- data/lib/graph_weaver/codegen/emit.rb +315 -76
- data/lib/graph_weaver/codegen/enum_type.rb +149 -0
- data/lib/graph_weaver/codegen/nodes.rb +123 -68
- data/lib/graph_weaver/codegen/scalar_type.rb +53 -35
- data/lib/graph_weaver/codegen.rb +272 -101
- data/lib/graph_weaver/errors.rb +37 -25
- data/lib/graph_weaver/hints.rb +63 -0
- data/lib/graph_weaver/inflect.rb +2 -1
- data/lib/graph_weaver/input_struct.rb +59 -0
- data/lib/graph_weaver/logging.rb +41 -0
- data/lib/graph_weaver/railtie.rb +30 -0
- data/lib/graph_weaver/retry.rb +97 -0
- data/lib/graph_weaver/rspec.rb +17 -14
- data/lib/graph_weaver/schema_loader.rb +156 -20
- data/lib/graph_weaver/selection.rb +3 -3
- data/lib/graph_weaver/tasks.rb +71 -0
- data/lib/graph_weaver/testing/cassette.rb +42 -21
- data/lib/graph_weaver/testing/failure.rb +22 -22
- data/lib/graph_weaver/testing/{fake_executor.rb → fake_client.rb} +13 -13
- data/lib/graph_weaver/testing/values.rb +2 -2
- data/lib/graph_weaver/testing.rb +31 -15
- data/lib/graph_weaver/transport/faraday.rb +56 -0
- data/lib/graph_weaver/transport/http.rb +87 -0
- data/lib/graph_weaver/transport.rb +120 -0
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +289 -38
- metadata +74 -5
- data/lib/graph_weaver/faraday_executor.rb +0 -61
- data/lib/graph_weaver/http_executor.rb +0 -44
- data/lib/graph_weaver/testing/rspec.rb +0 -5
data/lib/graph_weaver/testing.rb
CHANGED
|
@@ -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
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
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
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
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
|
|
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, :
|
|
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/
|
|
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
|
data/lib/graph_weaver/version.rb
CHANGED