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
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# Generated modules
|
|
2
|
+
|
|
3
|
+
`GraphWeaver::Codegen` turns one GraphQL operation into one `# typed: strict`
|
|
4
|
+
Ruby module. Everything `srb tc` knows about your query results comes from this
|
|
5
|
+
file — there is no runtime schema, no lazy wrapper, no reflection.
|
|
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
|
+
|
|
85
|
+
## Anatomy
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
module PersonQuery
|
|
89
|
+
QUERY = "..." # the operation, verbatim
|
|
90
|
+
|
|
91
|
+
class Result < T::Struct # the response shape, exactly as selected
|
|
92
|
+
class Person < T::Struct
|
|
93
|
+
const :name, String # non-null in the schema
|
|
94
|
+
const :birthday, T.nilable(Date)
|
|
95
|
+
|
|
96
|
+
def self.from_h(data) ... # generated casting — no reflection
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
const :person, T.nilable(Person)
|
|
100
|
+
end
|
|
101
|
+
|
|
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
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- `execute` returns the **envelope** — `GraphWeaver::Response[Result]` with
|
|
109
|
+
`#data`, `#data!`, `#errors`, `#extensions` — so partial data and
|
|
110
|
+
cost/throttle metadata survive.
|
|
111
|
+
- `execute!` is the shortcut: the typed result or a raised
|
|
112
|
+
`GraphWeaver::QueryError`.
|
|
113
|
+
|
|
114
|
+
## Variables become typed kwargs
|
|
115
|
+
|
|
116
|
+
```graphql
|
|
117
|
+
mutation($name: String!, $species: Species!, $note: String) { ... }
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
AddPetQuery.execute!(name: "Rex", species: AddPetQuery::Species::Dog)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
- required vs optional falls out of nullability and defaults: nullable or
|
|
125
|
+
defaulted variables become optional kwargs (nil is omitted from the wire,
|
|
126
|
+
so server-side defaults apply)
|
|
127
|
+
- enum variables generate module-level `T::Enum`s and accept the enum or
|
|
128
|
+
its wire value (`species: Species::Dog` or `species: "DOG"`)
|
|
129
|
+
- custom scalars serialize through the [scalar registry](scalars.md)
|
|
130
|
+
|
|
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
|
+
```
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
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):
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
AdoptQuery.execute!(input: { name: "Rex", species: "DOG" }, detail: true)
|
|
153
|
+
```
|
|
154
|
+
|
|
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
|
+
```
|
|
168
|
+
|
|
169
|
+
## Selections
|
|
170
|
+
|
|
171
|
+
- **Fragments** — inline fragments and named spreads flatten into the
|
|
172
|
+
selection; type conditions match exact names or interfaces/unions the type
|
|
173
|
+
belongs to.
|
|
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.
|
|
184
|
+
- **`@skip` / `@include`** — a directive-conditional field may be absent from
|
|
185
|
+
the response regardless of schema nullability, so its generated type is
|
|
186
|
+
always nilable.
|
|
187
|
+
- **Aliases** — result keys follow aliases; props are the underscored alias.
|
|
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
|
+
|
|
196
|
+
## Naming
|
|
197
|
+
|
|
198
|
+
Module names derive from the operation name (`query GetPerson` → `GetPerson`);
|
|
199
|
+
`GraphWeaver.parse` on a `.graphql` file derives from the file name
|
|
200
|
+
(`person.graphql` → `PersonQuery`). Pass `module_name:`/`name:` to override.
|
|
201
|
+
`Codegen.generate` requires a deliberate name for anonymous operations; dynamic
|
|
202
|
+
`parse` defaults to `Query` (its constants are container-scoped, so collisions
|
|
203
|
+
are impossible).
|
|
204
|
+
|
|
205
|
+
Nested struct names come from GraphQL type names, disambiguated one level by
|
|
206
|
+
field name on collision.
|
|
207
|
+
|
|
208
|
+
## Clients
|
|
209
|
+
|
|
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).
|
|
214
|
+
|
|
215
|
+
Generate *without* a baked constant when you want modules to follow the
|
|
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.
|
|
218
|
+
|
|
219
|
+
## Dynamic mode
|
|
220
|
+
|
|
221
|
+
`GraphWeaver.parse` generates + evals in one step (no build artifact, evaled
|
|
222
|
+
into an anonymous container — no global constants leak). Same runtime
|
|
223
|
+
semantics; invisible to `srb tc`, so prefer the build step where static
|
|
224
|
+
checking matters. `GraphWeaver.execute(schema:, query:, variables: {})` is
|
|
225
|
+
the one-shot form.
|
|
226
|
+
|
|
227
|
+
Generated source is eval'd, so inputs are validated: module names must be
|
|
228
|
+
constant names, and query heredocs can't be terminated early. Still: queries
|
|
229
|
+
are code — don't feed untrusted strings to parse.
|
|
@@ -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
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Against a real API
|
|
2
|
+
|
|
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:
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
require "graph_weaver"
|
|
14
|
+
|
|
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
|
+
|
|
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")
|
|
27
|
+
|
|
28
|
+
RepoQuery = github.parse(<<~GRAPHQL)
|
|
29
|
+
query($owner: String!, $name: String!) {
|
|
30
|
+
repository(owner: $owner, name: $name) {
|
|
31
|
+
nameWithOwner
|
|
32
|
+
createdAt
|
|
33
|
+
stargazerCount
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
GRAPHQL
|
|
37
|
+
|
|
38
|
+
repo = RepoQuery.execute!(owner: "dpep", name: "graph_weaver").repository
|
|
39
|
+
repo&.name_with_owner # => "dpep/graph_weaver"
|
|
40
|
+
repo&.created_at # => 2026-07-07 ... (a real Time)
|
|
41
|
+
repo&.stargazer_count # => Integer
|
|
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
|
+
|
|
51
|
+
The same flow runs as one-off integration specs against the live GitHub
|
|
52
|
+
and Countries APIs — `make integration` (network; GitHub auth via
|
|
53
|
+
`gh auth token` or `GITHUB_TOKEN`).
|
data/docs/scalars.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Custom scalars
|
|
2
|
+
|
|
3
|
+
Teach the generator how a GraphQL custom scalar deserializes into a rich
|
|
4
|
+
Ruby object (and serializes back when used as a variable). A field typed
|
|
5
|
+
`Money` then generates `const :price, T.nilable(Money)` and casts with
|
|
6
|
+
`Money.parse(...)` inline — no runtime reflection:
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
GraphWeaver.register_scalar("Money", Money, requires: "bigdecimal")
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Registrations are global by default. A [client](transports.md) scopes
|
|
13
|
+
them: `client.register_scalar(...)` overlays the global registry for that
|
|
14
|
+
client's generation only — so two servers can disagree about what a
|
|
15
|
+
`DateTime` is, and neither leaks into the other.
|
|
16
|
+
|
|
17
|
+
Pass a real class as `type:` and the cast/serialize are **inferred** from it by
|
|
18
|
+
probing the deserialize side and pairing its serializer:
|
|
19
|
+
|
|
20
|
+
| the class defines | cast | serialize |
|
|
21
|
+
|-------------------|---------------|----------------|
|
|
22
|
+
| `.parse` | `Type.parse(v)` | `v.to_s` |
|
|
23
|
+
| `.load` | `Type.load(v)` | `Type.dump(v)` |
|
|
24
|
+
|
|
25
|
+
so the common case needs nothing more. Probing the *deserialize* side is
|
|
26
|
+
deliberate — every object has `#to_s`, so inferring off it would wrongly wrap
|
|
27
|
+
plain types like `String`/`Integer`; requiring a `.parse`/`.load` the type
|
|
28
|
+
actually defines avoids that (and is why the built-in scalars — `Date`, `ID`,
|
|
29
|
+
`Int`, and friends, pre-registered and detailed below — can be registered with
|
|
30
|
+
their real class constants). Override explicitly when you need to:
|
|
31
|
+
|
|
32
|
+
- a `Symbol` method name, nothing to misspell: `cast: :load` → `Money.load(expr)`,
|
|
33
|
+
`serialize: :to_json` → `expr.to_json`
|
|
34
|
+
- a `Proc` for anything a method name can't express: `cast: ->(expr) { "Money.new(#{expr})" }`
|
|
35
|
+
- `:itself` to force pass-through, opting out of inference (rare)
|
|
36
|
+
|
|
37
|
+
`type:` also accepts a plain string (`"BigDecimal"`) when you'd rather not
|
|
38
|
+
reference the class. `requires:` (a string or array) names files emitted as
|
|
39
|
+
`require`s atop the generated source so the cast/type resolve. When `type:` is
|
|
40
|
+
a real class (so the runtime is loaded), each path is also `require`d at
|
|
41
|
+
registration — a typo fails now, not in the generated file.
|
|
42
|
+
|
|
43
|
+
Pass `coerce: true` to let a variable of this scalar accept **either** the value
|
|
44
|
+
object **or** its raw input, normalizing the latter through the cast:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
GraphWeaver.register_scalar("Money", Money, coerce: true)
|
|
48
|
+
# generated execute now takes T.any(Money, String); "12.00" is parsed
|
|
49
|
+
StoreQuery.execute(budget: "12.00") # Money.parse("12.00") under the hood
|
|
50
|
+
StoreQuery.execute(budget: Money.new(1200)) # passed straight through
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Bad input still explodes (the cast raises), so some safety survives; coercion
|
|
54
|
+
needs both a cast and a serialize. Off by default — the strict typed kwarg is the norm.
|
|
55
|
+
|
|
56
|
+
`coerce:` also takes a **Symbol** naming a conversion method, for built-ins where
|
|
57
|
+
a plain method is the whole story — `coerce: :to_f` makes a variable accept
|
|
58
|
+
`5`/`"5"` and `.to_f` it, sending a native number (not `"5.0"`) on the wire. The
|
|
59
|
+
convertible built-ins already know theirs (`Float`→`:to_f`, `Int`→`:to_i`,
|
|
60
|
+
`ID`/`String`→`:to_s`), so rather than opting in each, flip the default:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
GraphWeaver.auto_coerce = true
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Resolved lazily at generation time (set it any time before you generate),
|
|
67
|
+
it gives convertible built-ins their conversion and any scalar with a full
|
|
68
|
+
cast/serialize pair (`Date`, your `Money`) parse-style coercion; an explicit
|
|
69
|
+
`coerce:` on a registration always wins. `Boolean` has no lossless
|
|
70
|
+
one-method conversion, so it stays strict.
|
|
71
|
+
|
|
72
|
+
The built-in scalars (`Date`, `ID`, `Int`, …) are pre-registered through the
|
|
73
|
+
same path (`Date` even carries its own `require "date"`), so a later
|
|
74
|
+
`register_scalar` overrides them; `GraphWeaver.reset_scalars!` restores the
|
|
75
|
+
defaults (`reset_scalars!(coerce: true)` restores them coercible) and
|
|
76
|
+
`clear_scalars!` empties the registry. Register before generating — it's a
|
|
77
|
+
codegen-time concern, baked into the emitted source.
|
|
78
|
+
|
|
79
|
+
## Enums: map onto your own T::Enum
|
|
80
|
+
|
|
81
|
+
By default each generated module grows its own `T::Enum` per GraphQL
|
|
82
|
+
enum — `AddPetQuery::Species`, `SearchQuery::Result::...::Species`, one
|
|
83
|
+
per module that touches it. That's fine until your app has its own
|
|
84
|
+
domain enum, and then the boundary shuffle starts:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
# your domain already speaks PetKind — it's in your models, your
|
|
88
|
+
# ActiveRecord enum column, your case statements
|
|
89
|
+
class PetKind < T::Enum
|
|
90
|
+
enums { Cat = new("cat"); Dog = new("dog") }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# without a mapping, every call site converts by hand, in both directions
|
|
94
|
+
kind = PetKind.deserialize(pet.species.serialize.downcase) # response -> domain
|
|
95
|
+
AddPetQuery.execute!(species: kind.serialize.upcase) # domain -> wire
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Two enums for one concept, glue at every crossing, and each generated
|
|
99
|
+
module has its *own* incompatible `Species`, so a pet from `SearchQuery`
|
|
100
|
+
and a pet from `AddPetQuery` don't even compare. Register the mapping
|
|
101
|
+
once and the seam disappears — generated code speaks your enum
|
|
102
|
+
everywhere, casting wire values in and serializing members out:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
GraphWeaver.register_enum("Species", PetKind) # global
|
|
106
|
+
api.register_enums("Species" => PetKind, "Role" => Role) # or per client, in bulk
|
|
107
|
+
|
|
108
|
+
pet.species # => PetKind::Dog — compare, case, persist directly
|
|
109
|
+
pet.species == other_pet.species # same type across every query
|
|
110
|
+
AddPetQuery.execute!(species: PetKind::Cat) # or "CAT" — members and wire values both work
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**When to reach for it**: the enum has a life outside the API — it's
|
|
114
|
+
persisted, matched in business logic, or shared across queries. **When
|
|
115
|
+
not to bother**: display-only values you read and forget; the per-module
|
|
116
|
+
generated enums are self-contained and need zero setup.
|
|
117
|
+
|
|
118
|
+
The mapping is inferred by name (`"CAT"` ↔ `PetKind::Cat`,
|
|
119
|
+
case/underscore-insensitive against each member's serialized value), so
|
|
120
|
+
aligned enums need only the one line. When names diverge, `map:` pins the
|
|
121
|
+
exceptions and merges over inference:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
GraphWeaver.register_enum("Species", PetKind, map: { "FELINE" => PetKind::Cat })
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Two safety properties do the real work:
|
|
128
|
+
|
|
129
|
+
- **Exhaustiveness at generation**: every value the schema declares must
|
|
130
|
+
resolve to a member, or generation fails naming the gaps
|
|
131
|
+
(`PetKind has no member for Species value(s) DOG — add them, pin with
|
|
132
|
+
map:, or absorb with fallback:`). Your enum drifting from the server's
|
|
133
|
+
is caught at `rake graph_weaver:generate`, not in production.
|
|
134
|
+
- **`fallback:` for forward-compat**: `fallback: PetKind::Unknown` makes
|
|
135
|
+
*casting* absorb wire values the server added after you generated —
|
|
136
|
+
responses keep flowing instead of raising. Inputs stay strict either
|
|
137
|
+
way: a typo'd input is your bug, not drift.
|
|
138
|
+
|
|
139
|
+
The translation tables are emitted into the generated source
|
|
140
|
+
(`SPECIES_FROM_WIRE` / `SPECIES_TO_WIRE`) — reviewable in the diff, no
|
|
141
|
+
runtime registry. And because registration can be client-scoped, two
|
|
142
|
+
servers with different ideas of `"Species"` can map onto different (or
|
|
143
|
+
the same) domain enums without touching each other.
|
|
144
|
+
|
|
145
|
+
## Type helpers: your logic on generated structs
|
|
146
|
+
|
|
147
|
+
Derived values (display names, emoji, predicates) belong next to the
|
|
148
|
+
data but not *in* it — rewriting wire values on the way in destroys the
|
|
149
|
+
raw truth. Register a plain module and every struct generated from that
|
|
150
|
+
GraphQL type includes it, whatever query it appears in:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
module PetHelpers
|
|
154
|
+
def adult? = birthday && birthday < Date.today << 24
|
|
155
|
+
def display_name = adult? ? "#{name} 🦴" : "#{name} 🐶"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
GraphWeaver.register_type("Pet", PetHelpers) # or api.register_type(...)
|
|
159
|
+
|
|
160
|
+
pet.display_name # => "Shelby 🦴"
|
|
161
|
+
pet.name # => "Shelby" — the wire value stays honest
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Because the include is emitted into the generated source, `srb tc` checks
|
|
165
|
+
the helpers against each query's actual selection — a helper that calls
|
|
166
|
+
`birthday` on a query that never selected it is a **static error**, which
|
|
167
|
+
doubles as selection-completeness checking. Registrations are additive
|
|
168
|
+
(global plus client-scoped stack), and fakes/cassettes get the behavior
|
|
169
|
+
automatically since it lives on the struct.
|
|
170
|
+
|
|
171
|
+
For quick decoration, build the mixin inline — the block is
|
|
172
|
+
`module_eval`'d into a fresh module auto-named under
|
|
173
|
+
`GraphWeaver::TypeHelpers` so generated files can reference it:
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
api.register_type("Pet") do
|
|
177
|
+
def display_name = "#{name} 🐶"
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Same runtime behavior, one caveat: `srb tc` can't see into block-defined
|
|
182
|
+
methods, so prefer a named module where static checking matters —
|
|
183
|
+
complexity on demand.
|