graph_weaver 0.0.1 → 0.2.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.
- checksums.yaml +4 -4
- data/.yardopts +6 -0
- data/CHANGELOG.md +279 -0
- data/Gemfile.lock +75 -17
- data/Makefile +11 -1
- data/NOTES.md +1 -1
- data/PLAN.md +85 -22
- data/README.md +86 -26
- data/docs/cassettes.md +76 -0
- data/docs/errors.md +134 -0
- data/docs/generated_modules.md +229 -0
- data/docs/getting_started.md +134 -0
- data/docs/logging.md +33 -0
- data/docs/real_world.md +53 -0
- data/docs/scalars.md +183 -0
- data/docs/testing.md +103 -0
- data/docs/transports.md +124 -0
- data/graph_weaver.gemspec +10 -1
- data/lib/graph_weaver/client.rb +200 -0
- data/lib/graph_weaver/codegen/emit.rb +286 -0
- data/lib/graph_weaver/codegen/enum_type.rb +149 -0
- data/lib/graph_weaver/codegen/nodes.rb +356 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +256 -0
- data/lib/graph_weaver/codegen.rb +396 -401
- data/lib/graph_weaver/errors.rb +322 -0
- data/lib/graph_weaver/hints.rb +63 -0
- data/lib/graph_weaver/inflect.rb +19 -0
- data/lib/graph_weaver/logging.rb +41 -0
- data/lib/graph_weaver/railtie.rb +29 -0
- data/lib/graph_weaver/response.rb +55 -0
- data/lib/graph_weaver/retry.rb +97 -0
- data/lib/graph_weaver/rspec.rb +59 -0
- data/lib/graph_weaver/schema_loader.rb +208 -8
- data/lib/graph_weaver/selection.rb +68 -0
- data/lib/graph_weaver/tasks.rb +71 -0
- data/lib/graph_weaver/testing/cassette.rb +224 -0
- data/lib/graph_weaver/testing/failure.rb +109 -0
- data/lib/graph_weaver/testing/fake_client.rb +228 -0
- data/lib/graph_weaver/testing/values.rb +98 -0
- data/lib/graph_weaver/testing.rb +106 -0
- 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 +268 -4
- metadata +132 -2
- data/lib/graph_weaver/http_executor.rb +0 -31
data/docs/testing.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
Everything here is a *client* — the one interface queries run
|
|
4
|
+
through: anything with `execute(query, variables:)` returning
|
|
5
|
+
`{"data" => ..., "errors" => ...}` (see [transports](transports.md)).
|
|
6
|
+
Fakes, failures, and cassettes all slot in wherever a real transport
|
|
7
|
+
would.
|
|
8
|
+
|
|
9
|
+
`require "graph_weaver/rspec"` from your spec helper (or
|
|
10
|
+
`graph_weaver/testing` outside rspec — never in production) for a
|
|
11
|
+
zero-setup fake backend. `FakeClient` fabricates
|
|
12
|
+
schema-correct responses for whatever query arrives: real enum values,
|
|
13
|
+
valid `__typename` members, iso8601 date scalars — every fake casts
|
|
14
|
+
cleanly through your generated structs.
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
fake = GraphWeaver::Testing::FakeClient.new(schema:)
|
|
18
|
+
|
|
19
|
+
person = PersonQuery.execute!(fake, id: "1").person
|
|
20
|
+
person.name # => "Eliza Kertzmann" (faker-matched on field name, when faker is loaded)
|
|
21
|
+
person.birthday # => a real Date
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Pin what matters, keyed by GraphQL names (schema vocabulary — keys
|
|
25
|
+
survive query refactors); `"Type.field"` beats `"field"`:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
GraphWeaver::Testing::FakeClient.new(schema:, overrides: {
|
|
29
|
+
"Person.name" => "Daniel",
|
|
30
|
+
"email" => -> { "test@example.com" },
|
|
31
|
+
})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
With rspec, one line in `spec/support/graph_weaver.rb` is the whole
|
|
35
|
+
default setup — the schema auto-locates from the committed dump at
|
|
36
|
+
`GraphWeaver.schema_path`, and `auto_fake` defaults on:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
require "graph_weaver/rspec" # seed follows --seed; every example auto-fakes
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Configure to override any of it:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
GraphWeaver::Testing.configure do |config|
|
|
46
|
+
config.schema = MySchema # an in-process class instead of the located dump
|
|
47
|
+
config.auto_fake = false # opt out of per-example fakes
|
|
48
|
+
config.mode = :faker # or :literal (plain typed values); nil = auto
|
|
49
|
+
config.overrides = { "Person.name" => "Daniel" }
|
|
50
|
+
config.list_size = 1..3
|
|
51
|
+
config.null_chance = 0.1 # nullable fields go nil sometimes
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
With the rspec integration, `rspec --seed 1234` reproduces fake data
|
|
56
|
+
along with test order, and `auto_fake` installs a seeded fake as the
|
|
57
|
+
app client per example (generate modules *without* a baked `client:` so
|
|
58
|
+
they consult `GraphWeaver.client`). `mode:` picks value fabrication: `:faker`
|
|
59
|
+
(semantic, field-name matched — raises if the gem is missing),
|
|
60
|
+
`:literal` (plain type-derived), or nil to auto-detect faker.
|
|
61
|
+
|
|
62
|
+
**Simulating failures** — every failure mode is just a client, so
|
|
63
|
+
error-handling paths are testable without a server that misbehaves on cue:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
Failure = GraphWeaver::Testing::Failure
|
|
67
|
+
|
|
68
|
+
PersonQuery.execute(id: "1", client: Failure.transport) # TransportError (cause preserved)
|
|
69
|
+
PersonQuery.execute(id: "1", client: Failure.server(status: 502)) # ServerError
|
|
70
|
+
PersonQuery.execute(id: "1", client: Failure.throttled) # QueryError, code THROTTLED
|
|
71
|
+
PersonQuery.execute(id: "1", client: Failure.stale_schema) # schema_stale? => true
|
|
72
|
+
PersonQuery.execute(id: "1", client: Failure.graphql("boom", data: {...})) # partial failure
|
|
73
|
+
|
|
74
|
+
# retries: clients run in sequence (the last repeats) — here, two
|
|
75
|
+
# transport failures and then a FakeClient serving good responses
|
|
76
|
+
fake = GraphWeaver::Testing::FakeClient.new(schema:)
|
|
77
|
+
GraphWeaver::Testing::Sequence.new(Failure.transport, Failure.transport, fake)
|
|
78
|
+
|
|
79
|
+
# type mismatch: corrupt: derives a wrong-typed wire value for the field —
|
|
80
|
+
# casting raises GraphWeaver::TypeError (overrides remain the manual escape hatch)
|
|
81
|
+
GraphWeaver::Testing::FakeClient.new(schema:, corrupt: "Person.birthday")
|
|
82
|
+
|
|
83
|
+
# stale schema naming a real (sampled) field
|
|
84
|
+
Failure.stale_schema(schema: MySchema)
|
|
85
|
+
|
|
86
|
+
# field-level partial failure with real GraphQL null propagation: the error
|
|
87
|
+
# lands with its concrete path and nulls bubble to the nearest nullable spot
|
|
88
|
+
GraphWeaver::Testing::FakeClient.new(schema:, fail_at: { path: "person.email", code: "PRIVATE" })
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Capture and replay** — cassettes record real API responses and replay
|
|
92
|
+
them offline, above the transport (no HTTP interception):
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
# records against the live client when the file is missing, replays after
|
|
96
|
+
cassette = GraphWeaver::Testing::Cassette.use("github", client: live)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Re-record with `GRAPHWEAVER_RECORD=1`, anonymize before committing
|
|
100
|
+
(`config.anonymize = true` scrubs as recordings happen, or
|
|
101
|
+
`rake graph_weaver:cassettes:anonymize` after) — the full workflow guide
|
|
102
|
+
is **[cassettes](cassettes.md)**.
|
|
103
|
+
|
data/docs/transports.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Transports
|
|
2
|
+
|
|
3
|
+
A *client* is anything with `execute(query, variables:)` whose result
|
|
4
|
+
`to_h`s into `{"data" => ..., "errors" => ...}` — from a full
|
|
5
|
+
`GraphWeaver::Client` down to a schema class (in-process execution), a
|
|
6
|
+
[FakeClient](testing.md), or anything you write. Every slot that takes a
|
|
7
|
+
client accepts any of them.
|
|
8
|
+
|
|
9
|
+
A *transport* is the network end of that contract — GraphQL-over-HTTP. The bundled
|
|
10
|
+
two — `Transport::HTTP` (net/http, zero dependencies, loaded by default)
|
|
11
|
+
and `Transport::Faraday` (opt-in) — subclass `GraphWeaver::Transport`,
|
|
12
|
+
which owns the shared flow: encode the request, reclassify network
|
|
13
|
+
failures as `TransportError`, raise `ServerError` on non-2xx, parse the
|
|
14
|
+
body. A subclass only implements `post(body) => [status, body]` — that's
|
|
15
|
+
the whole recipe for bringing your own HTTP client.
|
|
16
|
+
|
|
17
|
+
## One-shot setup: a client
|
|
18
|
+
|
|
19
|
+
Most apps need one line:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
github = GraphWeaver.new("https://api.example.com/graphql", auth: ENV["API_TOKEN"])
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`GraphWeaver.new` builds a [`Client`](real_world.md): the best transport
|
|
26
|
+
with auth applied (exposed as `client.transport`), the schema
|
|
27
|
+
introspected lazily, and `parse`/`execute` bound to both.
|
|
28
|
+
|
|
29
|
+
- `auth:` — a token; "Bearer" is assumed unless the string carries its own
|
|
30
|
+
scheme (`"Basic dXNlcjpwYXNz..."`)
|
|
31
|
+
- `headers:` — anything else (API keys, custom headers)
|
|
32
|
+
- `retries:` — off by default; `true` for a `Retry` with defaults,
|
|
33
|
+
or a Hash of its options
|
|
34
|
+
- `cache:` / `ttl:` — schema introspection caching (see
|
|
35
|
+
[real world](real_world.md)); url clients only — a schema source never
|
|
36
|
+
introspects, so passing them raises
|
|
37
|
+
- a block customizes the Faraday connection (Faraday only — raises without it)
|
|
38
|
+
|
|
39
|
+
To wire generated modules that don't bake a client, make it the app's
|
|
40
|
+
default: `GraphWeaver.client = github`. Anything satisfying the execute
|
|
41
|
+
contract works there — testing's auto_fake swaps in a fake per example.
|
|
42
|
+
|
|
43
|
+
**Transport pick**: `Transport::Faraday` when the app already loads
|
|
44
|
+
faraday (its middleware/proxy/timeout ecosystem comes along), the
|
|
45
|
+
zero-dependency `Transport::HTTP` otherwise. Detection is `defined?(Faraday)` —
|
|
46
|
+
deliberately *not* a require: faraday rides along transitively in most
|
|
47
|
+
bundles (stripe, octokit, ...), and try-requiring would silently switch
|
|
48
|
+
transports on apps that never chose it. With faraday under
|
|
49
|
+
`require: false`, load it before building the client.
|
|
50
|
+
|
|
51
|
+
## Building blocks
|
|
52
|
+
|
|
53
|
+
The client is convenience, not the only door — construct and assign
|
|
54
|
+
yourself for full control:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
# zero-dependency Net::HTTP — persistent (keep-alive) connection,
|
|
58
|
+
# mutex-serialized; timeouts raise retriable TransportError
|
|
59
|
+
GraphWeaver::Transport::HTTP.new(
|
|
60
|
+
url,
|
|
61
|
+
headers: { ... },
|
|
62
|
+
open_timeout: 10, read_timeout: 30, # seconds (the defaults)
|
|
63
|
+
keep_alive_timeout: 2, # idle window before reconnecting
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Faraday: a url (+ optional middleware block), or a ready connection
|
|
67
|
+
GraphWeaver::Transport::Faraday.new(url) do |conn|
|
|
68
|
+
conn.request :authorization, "Bearer", -> { Tokens.fetch } # dynamic tokens
|
|
69
|
+
conn.response :logger
|
|
70
|
+
end
|
|
71
|
+
GraphWeaver::Transport::Faraday.new(MyApp.faraday_connection)
|
|
72
|
+
|
|
73
|
+
# One Faraday::Connection is reused for the transport's lifetime, but
|
|
74
|
+
# socket keep-alive depends on the ADAPTER: Faraday's default net_http
|
|
75
|
+
# adapter opens a fresh connection per request. For persistent sockets
|
|
76
|
+
# (and real pooling), pick a persistent adapter:
|
|
77
|
+
GraphWeaver::Transport::Faraday.new(url) do |conn|
|
|
78
|
+
conn.adapter :net_http_persistent # gem "net-http-persistent"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
GraphWeaver.client = ... # the app default (a Client or any of the above)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Client resolution
|
|
85
|
+
|
|
86
|
+
The canonical order — how a generated module finds its client (each slot
|
|
87
|
+
takes a `Client` or any bare transport/fake):
|
|
88
|
+
|
|
89
|
+
1. per call: `execute(some_client, ...)` — the optional first positional
|
|
90
|
+
argument, so variables keep the entire kwarg namespace
|
|
91
|
+
2. per module: `MyQuery.client = something`
|
|
92
|
+
3. baked constant: `Codegen.generate(..., client: MyApi::CLIENT)`
|
|
93
|
+
4. the app default: `GraphWeaver.client=`
|
|
94
|
+
|
|
95
|
+
Nothing set anywhere raises with a message saying which knobs exist.
|
|
96
|
+
|
|
97
|
+
## Retries
|
|
98
|
+
|
|
99
|
+
`Retry` wraps any client/transport:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
GraphWeaver::Retry.new(
|
|
103
|
+
inner_transport,
|
|
104
|
+
tries: 5, # total attempts, first included
|
|
105
|
+
backoff: :exponential, # or :linear, or ->(attempt) { seconds }
|
|
106
|
+
base: 0.5, max: 30, # seconds; delays clamp at max:
|
|
107
|
+
jitter: true, # randomize each delay by 50-100%
|
|
108
|
+
on: [GraphWeaver::TransportError, GraphWeaver::ServerError],
|
|
109
|
+
retry_if: ->(error) { ... }, # fine-grain within on:
|
|
110
|
+
retry_codes: ["THROTTLED"], # also retry GraphQL errors by code
|
|
111
|
+
)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Defaults: transport failures always retry (the request never arrived);
|
|
115
|
+
`ServerError` only on 5xx — a 4xx is a bug in the request, retrying won't
|
|
116
|
+
fix it. `retry_codes:` re-inspects response envelopes so GraphQL-level
|
|
117
|
+
throttling can retry too (off by default — pass the codes your API uses).
|
|
118
|
+
Exhausting `tries:` re-raises the last error (or returns the last
|
|
119
|
+
code-matched response).
|
|
120
|
+
|
|
121
|
+
Or via the client: `GraphWeaver.new(url, retries: { tries: 5, retry_codes: ["THROTTLED"] })`.
|
|
122
|
+
|
|
123
|
+
What classifies as a transport failure is an extensible set — see
|
|
124
|
+
[errors](errors.md#extending-transporterror) (`GraphWeaver.register_transport_error`).
|
data/graph_weaver.gemspec
CHANGED
|
@@ -3,7 +3,9 @@ require_relative "lib/graph_weaver/version"
|
|
|
3
3
|
Gem::Specification.new do |s|
|
|
4
4
|
s.authors = ["Daniel Pepper"]
|
|
5
5
|
s.description = "A typed GraphQL client for Ruby — generate Sorbet T::Structs from queries, with federation, extensibility, and testing in mind"
|
|
6
|
-
|
|
6
|
+
# ".yardopts" explicitly: `git ls-files *` skips dotfiles, and
|
|
7
|
+
# rubydoc.info needs it shipped to render docstrings as markdown
|
|
8
|
+
s.files = `git ls-files * ':!:spec' ':!:sorbet' ':!:bin' ':!:examples'`.split("\n") + [".yardopts"]
|
|
7
9
|
s.homepage = "https://github.com/dpep/graph_weaver"
|
|
8
10
|
s.license = "MIT"
|
|
9
11
|
s.name = "graph_weaver"
|
|
@@ -15,10 +17,17 @@ Gem::Specification.new do |s|
|
|
|
15
17
|
s.add_dependency "graphql", ">= 2"
|
|
16
18
|
s.add_dependency "sorbet-runtime"
|
|
17
19
|
|
|
20
|
+
s.add_development_dependency "apollo-federation" # federation integration subgraphs
|
|
21
|
+
s.add_development_dependency "bigdecimal"
|
|
18
22
|
s.add_development_dependency "debug"
|
|
23
|
+
s.add_development_dependency "faker"
|
|
24
|
+
s.add_development_dependency "faraday"
|
|
25
|
+
s.add_development_dependency "rake"
|
|
26
|
+
s.add_development_dependency "redcarpet" # yard --markup markdown
|
|
19
27
|
s.add_development_dependency "rspec"
|
|
20
28
|
s.add_development_dependency "simplecov"
|
|
21
29
|
s.add_development_dependency "sorbet"
|
|
22
30
|
s.add_development_dependency "tapioca"
|
|
23
31
|
s.add_development_dependency "webrick"
|
|
32
|
+
s.add_development_dependency "yard"
|
|
24
33
|
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "codegen"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "inflect"
|
|
7
|
+
require_relative "retry"
|
|
8
|
+
require_relative "schema_loader"
|
|
9
|
+
require_relative "transport/http"
|
|
10
|
+
|
|
11
|
+
# One object tying the whole flow together — transport, schema, and
|
|
12
|
+
# generation:
|
|
13
|
+
#
|
|
14
|
+
# github = GraphWeaver.new("https://api.github.com/graphql", auth: token, cache: true)
|
|
15
|
+
# github.register_scalar("DateTime", Time, serialize: :iso8601, requires: "time")
|
|
16
|
+
#
|
|
17
|
+
# RepoQuery = github.parse("queries/repo.graphql") # implicit schema + transport
|
|
18
|
+
# github.execute!("query { viewer { login } }") # one-shot
|
|
19
|
+
#
|
|
20
|
+
# The first argument is a url (a transport is built; the schema comes
|
|
21
|
+
# from introspection on first use, cached per cache:/ttl:) or a schema
|
|
22
|
+
# source — a live schema class (which also executes in-process), or a
|
|
23
|
+
# path/SDL/introspection dump via SchemaLoader. Pass transport: to
|
|
24
|
+
# bring your own transport for a schema source.
|
|
25
|
+
#
|
|
26
|
+
# Clients are independent: each has its own transport, schema, and
|
|
27
|
+
# scalar registrations, so one app can talk to several GraphQL servers —
|
|
28
|
+
# even ones that disagree about what a "DateTime" is.
|
|
29
|
+
class GraphWeaver::Client
|
|
30
|
+
URL = %r{\Ahttps?://}i
|
|
31
|
+
|
|
32
|
+
def initialize(source, auth: nil, headers: {}, retries: false, transport: nil, cache: nil, ttl: nil, &middleware)
|
|
33
|
+
if source.is_a?(String) && source.match?(URL)
|
|
34
|
+
raise ArgumentError, "pass a url or transport:, not both" if transport
|
|
35
|
+
|
|
36
|
+
@transport = wrap_retries(build_transport(source, auth:, headers:, &middleware), retries)
|
|
37
|
+
else
|
|
38
|
+
if auth || middleware || retries != false
|
|
39
|
+
raise ArgumentError, "auth:/retries:/middleware apply to a url — got a schema source"
|
|
40
|
+
end
|
|
41
|
+
if cache || ttl
|
|
42
|
+
# a schema source never introspects, so a cache would silently no-op
|
|
43
|
+
raise ArgumentError, "cache:/ttl: apply to url introspection — got a schema source"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# a live schema class doubles as an in-process transport; a loaded
|
|
47
|
+
# dump has no resolvers, so it is type information only
|
|
48
|
+
@schema = source.is_a?(Module) ? source : GraphWeaver::SchemaLoader.load(source)
|
|
49
|
+
@transport = transport || (source if source.is_a?(Module))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@cache = cache
|
|
53
|
+
@ttl = ttl
|
|
54
|
+
@scalars = {}
|
|
55
|
+
@enums = {}
|
|
56
|
+
@types = {}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The transport queries run through: a url-built transport, an
|
|
60
|
+
# explicit transport:, or the live schema class executing in-process.
|
|
61
|
+
# Clients are self-contained — the app default never leaks in; nil for
|
|
62
|
+
# schema-dump clients (type information only).
|
|
63
|
+
attr_reader :transport
|
|
64
|
+
|
|
65
|
+
# transport, when this client must be able to execute
|
|
66
|
+
def transport!
|
|
67
|
+
transport or raise GraphWeaver::Error,
|
|
68
|
+
"this client has no transport (built from a schema dump) — pass a url or transport:"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The schema, introspecting through the transport on first use (cached
|
|
72
|
+
# per the client's cache:/ttl:) unless one was given up front.
|
|
73
|
+
def schema
|
|
74
|
+
@schema ||= GraphWeaver::SchemaLoader.introspect(transport!, cache: @cache, ttl: @ttl)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Client-scoped scalar registration: consulted before the global
|
|
78
|
+
# registry when this client generates code, so two clients can map the
|
|
79
|
+
# same scalar name onto different Ruby types. Same signature as
|
|
80
|
+
# GraphWeaver.register_scalar.
|
|
81
|
+
def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
|
|
82
|
+
validate_registration!("scalar", graphql_name.to_s)
|
|
83
|
+
@scalars[graphql_name.to_s] =
|
|
84
|
+
GraphWeaver::Codegen::ScalarType.new(graphql_name, type, cast:, serialize:, requires:, coerce:)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Client-scoped enum mapping: this client's generated code speaks your
|
|
88
|
+
# T::Enum for the named GraphQL enum (see Codegen::EnumType — inference
|
|
89
|
+
# by name, map: for renames, fallback: to absorb unknown wire values).
|
|
90
|
+
def register_enum(graphql_name, type, map: nil, fallback: nil, requires: nil)
|
|
91
|
+
validate_registration!("enum", graphql_name.to_s)
|
|
92
|
+
@enums[graphql_name.to_s] =
|
|
93
|
+
GraphWeaver::Codegen::EnumType.new(graphql_name, type, map:, fallback:, requires:)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Bulk, inference-only form: register_enums("Species" => PetKind, ...)
|
|
97
|
+
def register_enums(mappings)
|
|
98
|
+
mappings.each { |graphql_name, type| register_enum(graphql_name, type) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Client-scoped type helpers: include app-owned modules into every
|
|
102
|
+
# struct this client generates from the named GraphQL type — pass
|
|
103
|
+
# modules, or a block to build one inline. Additive with global
|
|
104
|
+
# registrations (see GraphWeaver.register_type).
|
|
105
|
+
def register_type(graphql_name, *mixins, requires: nil, &block)
|
|
106
|
+
validate_registration!("type", graphql_name.to_s)
|
|
107
|
+
entry = @types[graphql_name.to_s] ||= { mixins: [], requires: [] }
|
|
108
|
+
GraphWeaver::Codegen.add_type_helpers(entry, graphql_name, mixins, requires, block)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Parse a query (a .graphql path or raw string) into a typed module
|
|
112
|
+
# bound to this client's schema, scalars, enums, helpers, and transport
|
|
113
|
+
# (including a live schema class executing in-process — the module came
|
|
114
|
+
# from this client, so it runs against it; pass a client per call to
|
|
115
|
+
# override, e.g. with a fake).
|
|
116
|
+
def parse(query, name: nil)
|
|
117
|
+
GraphWeaver.parse(schema:, query:, name:, client: transport,
|
|
118
|
+
scalars: @scalars, enums: @enums, types: @types)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Parse every .graphql query in a directory into typed modules, named
|
|
122
|
+
# like generation would name them — the no-build-step analog of
|
|
123
|
+
# generate! + load_generated!:
|
|
124
|
+
#
|
|
125
|
+
# github.load_queries! # queries/person.graphql => ::PersonQuery
|
|
126
|
+
# github.load_queries!(namespace: Github) # => Github::PersonQuery
|
|
127
|
+
#
|
|
128
|
+
# Reloadable (constants are replaced), so it suits consoles and dev.
|
|
129
|
+
# Returns the modules.
|
|
130
|
+
def load_queries!(dir = GraphWeaver.queries_path, namespace: Object)
|
|
131
|
+
Dir[File.join(dir, "*.graphql")].sort.map do |path|
|
|
132
|
+
name = "#{GraphWeaver::Inflect.camelize(File.basename(path, ".graphql"))}Query"
|
|
133
|
+
namespace.send(:remove_const, name) if namespace.const_defined?(name, false)
|
|
134
|
+
GraphWeaver.log(:info) { "loaded #{name} from #{path}" }
|
|
135
|
+
namespace.const_set(name, parse(path))
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# One-shot dynamic execution — parse + execute, returning the typed
|
|
140
|
+
# Response envelope (execute! returns the result or raises). Variables
|
|
141
|
+
# are plain kwargs, exactly as on a generated module; graphql-cased
|
|
142
|
+
# string keys work too.
|
|
143
|
+
def execute(query, **variables)
|
|
144
|
+
mod = parse(query)
|
|
145
|
+
kwargs = variables.to_h { |key, value| [GraphWeaver::Inflect.underscore(key.to_s).to_sym, value] }
|
|
146
|
+
mod.execute(transport!, **kwargs)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def execute!(query, **variables)
|
|
150
|
+
execute(query, **variables).data!
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
# Fail a typo'd registration at the call site when the schema is
|
|
156
|
+
# already in hand (a schema-source client, or a url client after first
|
|
157
|
+
# use) — immediate feedback in consoles. Lazily-introspecting clients
|
|
158
|
+
# get the same check at generation time instead; never trigger an
|
|
159
|
+
# introspection just to validate a name.
|
|
160
|
+
def validate_registration!(kind, name)
|
|
161
|
+
return unless @schema
|
|
162
|
+
|
|
163
|
+
GraphWeaver::Codegen.validate_registration!(@schema, kind, name)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# auth: is a token — "Bearer" is assumed unless the string carries its
|
|
167
|
+
# own scheme ("Basic dXNlcjpwYXNz..."). Transport pick: Faraday when
|
|
168
|
+
# the app already loads it (its middleware/proxy/timeout ecosystem
|
|
169
|
+
# comes along), the zero-dependency Transport::HTTP otherwise.
|
|
170
|
+
# Detection is `defined?(Faraday)` — deliberately NOT a require:
|
|
171
|
+
# faraday rides along transitively in most bundles (stripe, octokit,
|
|
172
|
+
# ...), and try-requiring would switch transports on apps that never
|
|
173
|
+
# chose it. With faraday under `require: false`, load it before
|
|
174
|
+
# building the client.
|
|
175
|
+
def build_transport(url, auth:, headers:, &middleware)
|
|
176
|
+
headers = headers.dup
|
|
177
|
+
if auth
|
|
178
|
+
headers["Authorization"] ||= auth.include?(" ") ? auth : "Bearer #{auth}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
if defined?(::Faraday)
|
|
182
|
+
require_relative "transport/faraday"
|
|
183
|
+
GraphWeaver::Transport::Faraday.new(url, headers:, &middleware)
|
|
184
|
+
elsif middleware
|
|
185
|
+
raise ArgumentError, "middleware blocks require the faraday gem"
|
|
186
|
+
else
|
|
187
|
+
GraphWeaver::Transport::HTTP.new(url, headers:)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# retries: is off by default — true for Retry defaults, or a
|
|
192
|
+
# Hash of its options
|
|
193
|
+
def wrap_retries(transport, retries)
|
|
194
|
+
case retries
|
|
195
|
+
when true then GraphWeaver::Retry.new(transport)
|
|
196
|
+
when false, nil then transport
|
|
197
|
+
else GraphWeaver::Retry.new(transport, **retries)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|