graph_weaver 0.1.0 → 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 +213 -0
- data/Gemfile.lock +32 -2
- data/Makefile +7 -2
- data/NOTES.md +1 -1
- data/PLAN.md +34 -1
- data/README.md +56 -41
- data/docs/cassettes.md +76 -0
- data/docs/errors.md +37 -9
- data/docs/generated_modules.md +139 -29
- data/docs/getting_started.md +134 -0
- data/docs/logging.md +33 -0
- data/docs/real_world.md +27 -20
- data/docs/scalars.md +122 -8
- data/docs/testing.md +44 -34
- data/docs/transports.md +124 -0
- data/graph_weaver.gemspec +7 -1
- data/lib/graph_weaver/client.rb +200 -0
- data/lib/graph_weaver/codegen/emit.rb +88 -39
- data/lib/graph_weaver/codegen/enum_type.rb +149 -0
- data/lib/graph_weaver/codegen/nodes.rb +109 -9
- data/lib/graph_weaver/codegen/scalar_type.rb +53 -35
- data/lib/graph_weaver/codegen.rb +301 -67
- 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/logging.rb +41 -0
- data/lib/graph_weaver/railtie.rb +29 -0
- data/lib/graph_weaver/retry.rb +97 -0
- data/lib/graph_weaver/rspec.rb +19 -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 +32 -16
- 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 +208 -38
- metadata +73 -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/docs/errors.md
CHANGED
|
@@ -30,7 +30,8 @@ it failed:
|
|
|
30
30
|
| `TransportError` | never reached the server — DNS, connection refused, TLS, timeout |
|
|
31
31
|
| `ServerError` | reached it, non-2xx HTTP — `#status`, `#body` |
|
|
32
32
|
| `QueryError` | 200 body with top-level GraphQL errors — `#errors`, `#data`, `#extensions`, `#codes` |
|
|
33
|
-
| `
|
|
33
|
+
| `TypeError` | the response wouldn't cast into the generated structs — `#struct`, `#cause` |
|
|
34
|
+
| `ValidationError` | build time: the query didn't validate against the schema |
|
|
34
35
|
|
|
35
36
|
```ruby
|
|
36
37
|
begin
|
|
@@ -38,22 +39,37 @@ begin
|
|
|
38
39
|
rescue GraphWeaver::TransportError
|
|
39
40
|
retry # network blip
|
|
40
41
|
rescue GraphWeaver::ServerError => e
|
|
41
|
-
|
|
42
|
+
e.status >= 500 ? backoff : raise # retry 5xx; a 4xx is our bug
|
|
42
43
|
rescue GraphWeaver::QueryError => e
|
|
43
44
|
e.codes.include?("THROTTLED") ? backoff : raise
|
|
44
45
|
end
|
|
45
46
|
```
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
Faraday executor adds its own), and you can register more so a custom adapter's
|
|
50
|
-
or connection pool's failure gets the same treatment:
|
|
48
|
+
Or skip the hand-rolling — `Retry` wraps any transport with
|
|
49
|
+
configurable retries:
|
|
51
50
|
|
|
52
51
|
```ruby
|
|
53
|
-
GraphWeaver.
|
|
54
|
-
GraphWeaver.
|
|
52
|
+
transport = GraphWeaver::Retry.new(
|
|
53
|
+
GraphWeaver::Transport::HTTP.new(url),
|
|
54
|
+
tries: 5, # total attempts
|
|
55
|
+
backoff: :exponential, # or :linear, or ->(attempt) { seconds }
|
|
56
|
+
base: 0.5, max: 30, # seconds, clamped at max:
|
|
57
|
+
jitter: true, # randomize each delay by 50-100%
|
|
58
|
+
retry_codes: ["THROTTLED"], # also retry GraphQL errors by code
|
|
59
|
+
)
|
|
55
60
|
```
|
|
56
61
|
|
|
62
|
+
Defaults match the rescue block above: transport failures always retry,
|
|
63
|
+
`ServerError` only on 5xx (a 4xx is your bug — retrying won't fix it;
|
|
64
|
+
override with `retry_if:`), and GraphQL-level codes only when listed in
|
|
65
|
+
`retry_codes:`. Exhausting `tries:` re-raises the last error.
|
|
66
|
+
|
|
67
|
+
Two deliberate exceptions live *outside* the hierarchy, because typed
|
|
68
|
+
kwargs should fail like any Ruby method call: a wrong-typed variable
|
|
69
|
+
raises sorbet-runtime's `TypeError` ("Parameter 'page': Expected type
|
|
70
|
+
T.nilable(Integer), got type String"), and a missing required variable
|
|
71
|
+
raises a plain `ArgumentError` ("missing keyword: :id").
|
|
72
|
+
|
|
57
73
|
Business/validation failures returned *as data* (Shopify-style `userErrors { field
|
|
58
74
|
message code }`) aren't errors here — they're just fields you selected, so they
|
|
59
75
|
deserialize onto `response.data` like anything else and you inspect them there.
|
|
@@ -61,6 +77,18 @@ deserialize onto `response.data` like anything else and you inspect them there.
|
|
|
61
77
|
The one-shot `GraphWeaver.execute` / `execute!` mirror this: `execute` returns
|
|
62
78
|
the envelope, `execute!` the result-or-raise.
|
|
63
79
|
|
|
80
|
+
## Extending TransportError
|
|
81
|
+
|
|
82
|
+
What counts as a `TransportError` is an **extensible set** — each transport
|
|
83
|
+
seeds its own network exceptions (`Errno::*`, `SocketError`, timeouts, TLS; the
|
|
84
|
+
Faraday transport adds its own), and you can register more so a custom adapter's
|
|
85
|
+
or connection pool's failure gets the same treatment:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
GraphWeaver.register_transport_error(ConnectionPool::TimeoutError)
|
|
89
|
+
GraphWeaver.transport_errors << MyAdapter::ResetError # it's just a Set
|
|
90
|
+
```
|
|
91
|
+
|
|
64
92
|
|
|
65
93
|
## Programmatic surfacing
|
|
66
94
|
|
|
@@ -102,5 +130,5 @@ When wire data disagrees with the types the schema promised at generation time
|
|
|
102
130
|
(a nil where non-null was declared, a malformed scalar, an unknown enum value),
|
|
103
131
|
casting raises `GraphWeaver::TypeError` naming the failing generated struct,
|
|
104
132
|
with the original exception as `#cause`. Simulate one in tests with
|
|
105
|
-
`
|
|
133
|
+
`GraphWeaver::Testing::FakeClient.new(schema:, corrupt: "Person.birthday")` — see
|
|
106
134
|
[testing](testing.md).
|
data/docs/generated_modules.md
CHANGED
|
@@ -4,6 +4,84 @@
|
|
|
4
4
|
Ruby module. Everything `srb tc` knows about your query results comes from this
|
|
5
5
|
file — there is no runtime schema, no lazy wrapper, no reflection.
|
|
6
6
|
|
|
7
|
+
This is the production path — checked in, reviewed, statically checked
|
|
8
|
+
(assembled step by step in the [getting started](getting_started.md), including
|
|
9
|
+
[what Sorbet does and doesn't require](getting_started.md#sorbet-with-or-without)).
|
|
10
|
+
For consoles and dev there's [dynamic mode](#dynamic-mode); for one-off
|
|
11
|
+
scripts, `client.execute!` skips modules entirely.
|
|
12
|
+
|
|
13
|
+
## Generating
|
|
14
|
+
|
|
15
|
+
The workflow that keeps generated code honest: queries live as `.graphql`
|
|
16
|
+
files (the source of truth), generation writes the Ruby, and verification
|
|
17
|
+
fails when the two drift. The conventional layout (configurable via
|
|
18
|
+
`GraphWeaver.queries_path` / `generated_path` / `schema_path`):
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
app/graphql/
|
|
22
|
+
schema.json # introspection dump (or schema.graphql SDL)
|
|
23
|
+
queries/ # *.graphql — hand-written, reviewed
|
|
24
|
+
generated/ # *_query.rb — generated, checked in, never edited
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The schema dump is step 0 — codegen reads it, never a live endpoint.
|
|
28
|
+
`cache: true` on a url client writes it on first introspection
|
|
29
|
+
(`GraphWeaver.new(url, cache: true).schema` in a console bootstraps it);
|
|
30
|
+
generating without one fails pointing at exactly that.
|
|
31
|
+
|
|
32
|
+
Rake tasks (self-registering in Rails; elsewhere add
|
|
33
|
+
`require "graph_weaver/tasks"` to your Rakefile):
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
rake graph_weaver:generate # queries_path -> generated_path
|
|
37
|
+
rake graph_weaver:verify # fail if anything is stale — run in CI
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Scalar/enum/type registrations are baked into generated source, so they
|
|
41
|
+
must run before the tasks do. In Rails they will — the tasks depend on
|
|
42
|
+
`:environment`, which runs your initializers. Outside Rails, require the
|
|
43
|
+
file that does your registrations from the Rakefile yourself.
|
|
44
|
+
|
|
45
|
+
Or call the same APIs directly:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
|
|
49
|
+
GraphWeaver.generate!(schema:) # write the modules
|
|
50
|
+
GraphWeaver.verify_generated!(schema:) # the freshness guard, one line in a spec
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
In Rails, loading is automatic — the Railtie requires every file under
|
|
54
|
+
`generated_path` at boot, after your initializers (so registrations run
|
|
55
|
+
first). Elsewhere it's explicit, factory_bot-style:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
GraphWeaver.load_generated! # require every file under generated_path
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
(Plain requires, not Zeitwerk: Zeitwerk would expect
|
|
62
|
+
`Generated::PersonQuery` from `generated/person_query.rb`, and generated
|
|
63
|
+
code only changes on regeneration — restart, like a schema migration.)
|
|
64
|
+
|
|
65
|
+
Regenerate when: a query changes, the schema changes (a
|
|
66
|
+
[`schema_stale?`](errors.md) error in production is the late signal — refresh
|
|
67
|
+
the schema dump and regenerate), a scalar registration changes, or GraphWeaver
|
|
68
|
+
itself upgrades (emission may differ across versions; `verify_generated!`
|
|
69
|
+
catches it).
|
|
70
|
+
|
|
71
|
+
Introspected dumps record their source url, so drift is checkable ahead
|
|
72
|
+
of the late signal: `rake graph_weaver:schema:verify` re-introspects the
|
|
73
|
+
recorded url and fails when the server has moved;
|
|
74
|
+
`rake graph_weaver:schema:refresh` rewrites the dump
|
|
75
|
+
(`GRAPHWEAVER_AUTH` supplies a token for private APIs). Don't confuse
|
|
76
|
+
the two verifies: `graph_weaver:verify` asks "is the generated code
|
|
77
|
+
fresh?" — local, every CI run; `graph_weaver:schema:verify` asks "has the
|
|
78
|
+
*server* drifted from the dump?" — network, needs the recorded url, run
|
|
79
|
+
on a schedule.
|
|
80
|
+
|
|
81
|
+
In development, skip the build entirely — `client.load_queries!` parses
|
|
82
|
+
every query file into modules with the same names generation would use
|
|
83
|
+
(see [dynamic mode](#dynamic-mode)).
|
|
84
|
+
|
|
7
85
|
## Anatomy
|
|
8
86
|
|
|
9
87
|
```ruby
|
|
@@ -21,9 +99,9 @@ module PersonQuery
|
|
|
21
99
|
const :person, T.nilable(Person)
|
|
22
100
|
end
|
|
23
101
|
|
|
24
|
-
def self.
|
|
25
|
-
def self.execute(id
|
|
26
|
-
def self.execute!(id
|
|
102
|
+
def self.client ... # default client (see below)
|
|
103
|
+
def self.execute(client = nil, id:) # -> GraphWeaver::Response[Result]
|
|
104
|
+
def self.execute!(client = nil, id:) # -> Result, or raises QueryError
|
|
27
105
|
end
|
|
28
106
|
```
|
|
29
107
|
|
|
@@ -46,40 +124,75 @@ AddPetQuery.execute!(name: "Rex", species: AddPetQuery::Species::Dog)
|
|
|
46
124
|
- required vs optional falls out of nullability and defaults: nullable or
|
|
47
125
|
defaulted variables become optional kwargs (nil is omitted from the wire,
|
|
48
126
|
so server-side defaults apply)
|
|
49
|
-
- enum variables generate module-level `T::Enum`s and
|
|
127
|
+
- enum variables generate module-level `T::Enum`s and accept the enum or
|
|
128
|
+
its wire value (`species: Species::Dog` or `species: "DOG"`)
|
|
50
129
|
- custom scalars serialize through the [scalar registry](scalars.md)
|
|
51
130
|
|
|
52
|
-
**Input objects
|
|
53
|
-
(
|
|
54
|
-
|
|
131
|
+
**Input objects**: when an operation's only variable is a required input
|
|
132
|
+
object (the Relay convention), the input's fields flatten straight into
|
|
133
|
+
`execute`'s kwargs — no wrapper at the call site:
|
|
134
|
+
|
|
135
|
+
```graphql
|
|
136
|
+
mutation($input: AdoptionInput!) { adopt(input: $input) { ... } }
|
|
137
|
+
```
|
|
55
138
|
|
|
56
139
|
```ruby
|
|
57
|
-
|
|
58
|
-
|
|
140
|
+
AdoptQuery.execute!(name: "Rex", species: "DOG", nickname: "Rexy")
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The wrapping level is rebuilt on the wire, and each field type-checks
|
|
144
|
+
exactly like a variable would. Operations with more than one variable (or
|
|
145
|
+
a nullable input) keep the variable-per-kwarg surface — there the input
|
|
146
|
+
kwarg accepts the generated `T::Struct` or a plain hash (`.coerce`
|
|
147
|
+
normalizes underscored Symbol/String keys; enums accept wire values;
|
|
148
|
+
nested inputs accept hashes; unknown keys raise with a spellchecked
|
|
149
|
+
hint rather than silently dropping):
|
|
59
150
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
# nested inputs accept hashes)
|
|
63
|
-
AdoptQuery.execute!(input: { name: "Rex", species: "DOG" })
|
|
151
|
+
```ruby
|
|
152
|
+
AdoptQuery.execute!(input: { name: "Rex", species: "DOG" }, detail: true)
|
|
64
153
|
```
|
|
65
154
|
|
|
66
|
-
|
|
67
|
-
|
|
155
|
+
The structs themselves are module-level (`AdoptQuery::AdoptionInput`) with
|
|
156
|
+
`serialize` (aliased as `to_h`) producing the wire hash — optional fields
|
|
157
|
+
default nil and stay off the wire. Nested inputs work (dependencies emit
|
|
158
|
+
first), including recursive ones — Hasura's self-referential `bool_exp`
|
|
159
|
+
filters generate cleanly (`_and:`/`_not:` fields typed as the struct
|
|
160
|
+
itself), so variable-driven filtering works:
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
where = mod::PokemonBoolExp.coerce(
|
|
164
|
+
_and: [{ name: { _like: "%chu" } }, { _not: { name: { _eq: "raichu" } } }],
|
|
165
|
+
)
|
|
166
|
+
mod.execute!(where:)
|
|
167
|
+
```
|
|
68
168
|
|
|
69
169
|
## Selections
|
|
70
170
|
|
|
71
171
|
- **Fragments** — inline fragments and named spreads flatten into the
|
|
72
172
|
selection; type conditions match exact names or interfaces/unions the type
|
|
73
173
|
belongs to.
|
|
74
|
-
- **Unions and interfaces** —
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
`__typename`
|
|
174
|
+
- **Unions and interfaces** — when the selection *varies by concrete
|
|
175
|
+
type*, each abstract site emits a module: one member struct per
|
|
176
|
+
possible type, `Type = T.type_alias { T.any(...) }`, and a `from_h`
|
|
177
|
+
dispatching on `__typename` — which generation therefore *requires* in
|
|
178
|
+
the selection (the wire response carries no type tag unless you ask).
|
|
179
|
+
Two narrower shapes skip the dispatch (and the `__typename`) entirely:
|
|
180
|
+
interface-level fields only → one shared struct; a single `... on X`
|
|
181
|
+
condition and nothing else → `X`'s struct, always nilable — a
|
|
182
|
+
non-matching runtime type comes back as `nil`, so narrowing doubles as
|
|
183
|
+
filtering.
|
|
78
184
|
- **`@skip` / `@include`** — a directive-conditional field may be absent from
|
|
79
185
|
the response regardless of schema nullability, so its generated type is
|
|
80
186
|
always nilable.
|
|
81
187
|
- **Aliases** — result keys follow aliases; props are the underscored alias.
|
|
82
188
|
|
|
189
|
+
Props are always snake_case (`nameWithOwner` → `name_with_owner`). Reaching
|
|
190
|
+
for the wire name is a classic stumble, so it fails helpfully at both
|
|
191
|
+
layers: `srb tc` flags it statically, and at runtime (consoles, dynamic
|
|
192
|
+
mode) the struct raises a NoMethodError naming the prop that does exist —
|
|
193
|
+
`use 'name_with_owner'` for the exact wire name, `did you mean ...?` for
|
|
194
|
+
a near-miss typo in either casing.
|
|
195
|
+
|
|
83
196
|
## Naming
|
|
84
197
|
|
|
85
198
|
Module names derive from the operation name (`query GetPerson` → `GetPerson`);
|
|
@@ -92,19 +205,16 @@ are impossible).
|
|
|
92
205
|
Nested struct names come from GraphQL type names, disambiguated one level by
|
|
93
206
|
field name on collision.
|
|
94
207
|
|
|
95
|
-
##
|
|
96
|
-
|
|
97
|
-
An executor is anything with `execute(query, variables:)` whose result `to_h`s
|
|
98
|
-
into `{"data" => ..., "errors" => ...}`. Resolution order:
|
|
208
|
+
## Clients
|
|
99
209
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
210
|
+
A client is anything with `execute(query, variables:)` whose result `to_h`s
|
|
211
|
+
into `{"data" => ..., "errors" => ...}`. Resolution: per call → per module →
|
|
212
|
+
baked constant → `GraphWeaver.client` — the
|
|
213
|
+
canonical list lives in [transports](transports.md#client-resolution).
|
|
104
214
|
|
|
105
215
|
Generate *without* a baked constant when you want modules to follow the
|
|
106
|
-
|
|
107
|
-
fake per example.
|
|
216
|
+
app default (`GraphWeaver.client =` in an initializer) — that's also what
|
|
217
|
+
lets [testing's auto_fake](testing.md) swap in a fake per example.
|
|
108
218
|
|
|
109
219
|
## Dynamic mode
|
|
110
220
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Getting started: the production path (Rails)
|
|
2
|
+
|
|
3
|
+
The setup that ships: queries live as `.graphql` files, generation writes
|
|
4
|
+
`# typed: strict` Ruby you check in, and CI fails when anything drifts.
|
|
5
|
+
Mostly copy/paste. (Exploring an API from a console instead? Start with
|
|
6
|
+
[dynamic mode](real_world.md) — no build step.)
|
|
7
|
+
|
|
8
|
+
Rails is assumed below; the [non-Rails note](#not-rails) at the bottom
|
|
9
|
+
covers the one difference.
|
|
10
|
+
|
|
11
|
+
## 1. Install
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Gemfile
|
|
15
|
+
gem "graph_weaver"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 2. Bootstrap the schema dump
|
|
19
|
+
|
|
20
|
+
Codegen reads a schema dump at `app/graphql/schema.json`
|
|
21
|
+
(`GraphWeaver.schema_path`). You never write this file by hand —
|
|
22
|
+
`cache: true` writes it on first introspection. Bootstrap once from a
|
|
23
|
+
console:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
GraphWeaver.new("https://api.example.com/graphql", auth: ENV["API_TOKEN"], cache: true).schema
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Skip this step and the generate task tells you exactly that — the error
|
|
30
|
+
message is the documentation. Prefer PR-reviewable diffs? `cache: :graphql`
|
|
31
|
+
writes SDL instead of introspection JSON; both generate identical code.
|
|
32
|
+
|
|
33
|
+
Note `cache:`/`ttl:` apply only to url clients — a schema source (a live
|
|
34
|
+
class or a dump) never introspects, so passing them raises.
|
|
35
|
+
|
|
36
|
+
## 3. Wire the client
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# config/initializers/graph_weaver.rb
|
|
40
|
+
GraphWeaver.client = GraphWeaver.new(
|
|
41
|
+
"https://api.example.com/graphql",
|
|
42
|
+
auth: ENV["API_TOKEN"],
|
|
43
|
+
cache: true, # reuses the committed dump; delete the file to re-introspect
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# custom scalars/enums/type helpers — register globally, so the rake
|
|
47
|
+
# tasks bake them into generated source
|
|
48
|
+
GraphWeaver.register_scalar("DateTime", Time, serialize: :iso8601, requires: "time")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`GraphWeaver.client =` is the load-bearing line: generated modules
|
|
52
|
+
without a baked transport resolve to it at execute time (the full
|
|
53
|
+
[resolution order](transports.md#client-resolution)). The generated
|
|
54
|
+
modules themselves load at boot automatically (the Railtie requires
|
|
55
|
+
everything under `generated_path`, after your initializers run) —
|
|
56
|
+
outside Rails, call `GraphWeaver.load_generated!` wherever your app
|
|
57
|
+
boots.
|
|
58
|
+
|
|
59
|
+
## 4. Rake tasks — nothing to do
|
|
60
|
+
|
|
61
|
+
In Rails the `graph_weaver:*` tasks register themselves (a Railtie), and
|
|
62
|
+
they depend on `:environment`, so your initializer — and its
|
|
63
|
+
registrations, which are baked into generated source — runs first.
|
|
64
|
+
Outside Rails, add `require "graph_weaver/tasks"` to your Rakefile.
|
|
65
|
+
|
|
66
|
+
## 5. Write a query, generate, commit
|
|
67
|
+
|
|
68
|
+
```graphql
|
|
69
|
+
# app/graphql/queries/person.graphql
|
|
70
|
+
query($id: ID!) {
|
|
71
|
+
person(id: $id) {
|
|
72
|
+
name
|
|
73
|
+
birthday
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
rake graph_weaver:generate # writes app/graphql/generated/person_query.rb
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Commit the schema dump and the generated files. Generated code is
|
|
83
|
+
reviewed like any other code — and never edited by hand.
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
PersonQuery.execute!(id: "1").person&.name # typed, via GraphWeaver.client
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## 6. Test against fakes
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
# spec/support/graph_weaver.rb
|
|
93
|
+
require "graph_weaver/rspec"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
That's the whole setup: the schema auto-locates from the committed dump
|
|
97
|
+
and `auto_fake` defaults on, so every query in every example executes
|
|
98
|
+
against a seeded, schema-correct `FakeClient` — no server, no stubs,
|
|
99
|
+
and `rspec --seed 1234` reproduces the fake data along with test order.
|
|
100
|
+
Pin values with `overrides:`, simulate failures with `Failure.*`, opt
|
|
101
|
+
out with `config.auto_fake = false` — see [testing](testing.md).
|
|
102
|
+
|
|
103
|
+
## 7. Verify in CI
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
rake graph_weaver:verify # generated code fresh? fails on any drift
|
|
107
|
+
rake graph_weaver:schema:verify # server drifted? re-introspects and compares
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Two different questions. `graph_weaver:verify` checks that the committed
|
|
111
|
+
generated files match what the current schema + queries + registrations
|
|
112
|
+
would produce — run it in every CI build. `graph_weaver:schema:verify`
|
|
113
|
+
asks whether the *server* has moved since the dump was taken — it needs
|
|
114
|
+
network, a dump with a recorded source url (introspected dumps have one),
|
|
115
|
+
and `GRAPHWEAVER_AUTH` for private APIs; run it on a schedule and refresh
|
|
116
|
+
with `rake graph_weaver:schema:refresh`.
|
|
117
|
+
|
|
118
|
+
## Sorbet, with or without
|
|
119
|
+
|
|
120
|
+
`sorbet-runtime` is a hard dependency, so generated `T::Struct`s and sigs
|
|
121
|
+
enforce at runtime in every app — no Sorbet setup required on your end.
|
|
122
|
+
The *static* layer (`srb tc` flagging a typo'd field before anything
|
|
123
|
+
runs) applies only when your app runs Sorbet, and only to checked-in
|
|
124
|
+
generated files — dynamic `parse` is invisible to `srb tc`. Everything
|
|
125
|
+
works without Sorbet; codegen plus Sorbet is what moves type errors from
|
|
126
|
+
runtime to CI.
|
|
127
|
+
|
|
128
|
+
## Not Rails?
|
|
129
|
+
|
|
130
|
+
Everything above works the same, minus the Railtie conveniences: add
|
|
131
|
+
`require "graph_weaver/tasks"` to your Rakefile yourself, and — since
|
|
132
|
+
there's no `:environment` hook to run your registrations — require the
|
|
133
|
+
file that does them from the Rakefile too. `GraphWeaver.load_generated!`
|
|
134
|
+
goes wherever your app boots instead of an initializer.
|
data/docs/logging.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Logging
|
|
2
|
+
|
|
3
|
+
Silent by default. Point `GraphWeaver.logger` at anything
|
|
4
|
+
stdlib-Logger-compatible and the whole flow narrates itself — in Rails
|
|
5
|
+
the railtie wires `Rails.logger` automatically (set
|
|
6
|
+
`GraphWeaver.logger = nil` in an initializer to opt out):
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
GraphWeaver.logger = Logger.new($stdout, level: Logger::INFO)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
What logs at which level — pick the level, get the story:
|
|
13
|
+
|
|
14
|
+
| Level | What you see |
|
|
15
|
+
|-------|--------------|
|
|
16
|
+
| `debug` | the wire: query + variables per call (long queries truncated), response status/bytes, request timing, connection open/drop, dynamically parsed modules |
|
|
17
|
+
| `info` | schema introspection (with timing) and cache hits/misses, generated files written, query modules loaded |
|
|
18
|
+
| `warn` | every GraphWeaver error raised — `TransportError`, `ServerError`, `QueryError`, `ValidationError`, `TypeError` |
|
|
19
|
+
|
|
20
|
+
Every line carries `graph_weaver` as the progname, so formatter-based
|
|
21
|
+
filtering works out of the box. Wire lines are tagged
|
|
22
|
+
`[req 3 FilteredPokemon]` — a per-process request id plus the operation
|
|
23
|
+
name — so a request's lines stay paired when threads interleave.
|
|
24
|
+
|
|
25
|
+
Debugging a misbehaving integration is the intended use: crank to
|
|
26
|
+
`Logger::DEBUG` and you'll see exactly what went on the wire, what came
|
|
27
|
+
back, whether the schema came from cache or a live introspection, and
|
|
28
|
+
which connection served it.
|
|
29
|
+
|
|
30
|
+
**PII note**: queries, variables, and response sizes appear at debug
|
|
31
|
+
only — variables can carry user data, so keep production loggers at
|
|
32
|
+
info or above (or scrub in your formatter). Auth headers never log at
|
|
33
|
+
any level.
|
data/docs/real_world.md
CHANGED
|
@@ -1,30 +1,31 @@
|
|
|
1
1
|
# Against a real API
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
The exploratory tour: point a client at a live endpoint and go, no build
|
|
4
|
+
step — ideal for consoles, spikes, and getting a feel for an API. What
|
|
5
|
+
ships is the checked-in codegen path in the [getting started](getting_started.md);
|
|
6
|
+
this page is how you get there (the `parse` below becomes a `.graphql`
|
|
7
|
+
file plus `rake graph_weaver:generate`, everything else stays).
|
|
8
|
+
|
|
9
|
+
Everything hangs off a client — transport, schema, and scalars for one
|
|
10
|
+
server. GitHub's API, end to end:
|
|
6
11
|
|
|
7
12
|
```ruby
|
|
8
13
|
require "graph_weaver"
|
|
9
14
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
schema = GraphWeaver::SchemaLoader.introspect(
|
|
19
|
-
executor,
|
|
20
|
-
cache: "tmp/github-schema.json",
|
|
21
|
-
ttl: 24 * 60 * 60,
|
|
22
|
-
)
|
|
15
|
+
# transport + auth in one object (see docs/transports.md for retries and
|
|
16
|
+
# advanced setup). cache: true dumps the schema at GraphWeaver.schema_path
|
|
17
|
+
# on first introspection — the same file rake graph_weaver:generate reads —
|
|
18
|
+
# and any fresh dump already present is reused regardless of format. The
|
|
19
|
+
# extension picks the format: .json verbatim, .graphql SDL (reviewable
|
|
20
|
+
# diffs) — or say cache: :graphql. Introspected dumps record their source
|
|
21
|
+
# url in a header, so a stale dump says where it came from.
|
|
22
|
+
github = GraphWeaver.new("https://api.github.com/graphql", auth: `gh auth token`.strip, cache: true)
|
|
23
23
|
|
|
24
|
-
# map GitHub's DateTime scalar onto Time (cast inferred from Time.parse)
|
|
25
|
-
GraphWeaver.register_scalar
|
|
24
|
+
# map GitHub's DateTime scalar onto Time (cast inferred from Time.parse) —
|
|
25
|
+
# scoped to this client; GraphWeaver.register_scalar sets the global default
|
|
26
|
+
github.register_scalar("DateTime", Time, serialize: :iso8601, requires: "time")
|
|
26
27
|
|
|
27
|
-
RepoQuery =
|
|
28
|
+
RepoQuery = github.parse(<<~GRAPHQL)
|
|
28
29
|
query($owner: String!, $name: String!) {
|
|
29
30
|
repository(owner: $owner, name: $name) {
|
|
30
31
|
nameWithOwner
|
|
@@ -40,7 +41,13 @@ repo&.created_at # => 2026-07-07 ... (a real Time)
|
|
|
40
41
|
repo&.stargazer_count # => Integer
|
|
41
42
|
```
|
|
42
43
|
|
|
44
|
+
Clients are independent — build one per server, each with its own
|
|
45
|
+
transport, schema, and scalar mappings. The introspection step (seconds
|
|
46
|
+
on a big API) happens lazily on first `schema`/`parse` and caches per
|
|
47
|
+
`cache:`/`ttl:`; for finer control the pieces are all public
|
|
48
|
+
(`GraphWeaver::SchemaLoader.introspect(transport, cache:, ttl:)`, or cache
|
|
49
|
+
`introspect(transport).to_json` in Rails.cache and `SchemaLoader.load` it).
|
|
50
|
+
|
|
43
51
|
The same flow runs as one-off integration specs against the live GitHub
|
|
44
52
|
and Countries APIs — `make integration` (network; GitHub auth via
|
|
45
53
|
`gh auth token` or `GITHUB_TOKEN`).
|
|
46
|
-
|