graph_weaver 0.4.4 → 0.5.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/CHANGELOG.md +1357 -0
- data/CLAUDE.md +100 -8
- data/DECISIONS.md +309 -0
- data/Gemfile.lock +23 -23
- data/NOTES.md +5 -5
- data/PLAN.md +106 -135
- data/README.md +115 -96
- data/REVIEW.md +946 -0
- data/docs/cassettes.md +75 -48
- data/docs/editors.md +82 -0
- data/docs/errors.md +32 -30
- data/docs/federation.md +520 -48
- data/docs/generated_modules.md +352 -137
- data/docs/getting_started.md +237 -67
- data/docs/logging.md +35 -6
- data/docs/real_world.md +21 -15
- data/docs/scalars.md +49 -136
- data/docs/testing.md +299 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +112 -0
- data/graph_weaver.gemspec +3 -1
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +114 -111
- data/lib/graph_weaver/codegen/aliases.rb +217 -0
- data/lib/graph_weaver/codegen/emit.rb +272 -251
- data/lib/graph_weaver/codegen/enum_type.rb +27 -98
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +617 -264
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +12 -6
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +21 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +67 -0
- data/lib/graph_weaver/query_module.rb +55 -0
- data/lib/graph_weaver/railtie.rb +23 -1
- data/lib/graph_weaver/representation.rb +74 -0
- data/lib/graph_weaver/response.rb +15 -1
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +820 -57
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +59 -7
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +186 -62
- data/lib/graph_weaver/testing/coverage.rb +165 -0
- data/lib/graph_weaver/testing/failure.rb +10 -23
- data/lib/graph_weaver/testing/fake_client.rb +194 -28
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1431 -0
- data/lib/graph_weaver/testing/subgraphs.rb +130 -0
- data/lib/graph_weaver/testing.rb +204 -14
- data/lib/graph_weaver/transport/faraday.rb +31 -6
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +74 -18
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +398 -170
- metadata +20 -3
data/docs/transports.md
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
# Transports
|
|
2
2
|
|
|
3
|
-
A *client* is anything with `execute(query, variables:)` whose result
|
|
3
|
+
A *client* is anything with `execute(query, variables:, operation_name:)` whose result
|
|
4
4
|
`to_h`s into `{"data" => ..., "errors" => ...}` — from a full
|
|
5
|
-
`GraphWeaver::Client` down to a schema class
|
|
6
|
-
[
|
|
7
|
-
|
|
5
|
+
`GraphWeaver::Client` down to a schema class
|
|
6
|
+
([in-process execution](getting_started.md#your-apps-own-schema-in-process) —
|
|
7
|
+
typed access to your own app's API, no socket), a [FakeClient](testing.md), or
|
|
8
|
+
anything you write. Every slot that takes a client accepts any of them.
|
|
9
|
+
|
|
10
|
+
**Anything holding a schema parses against it.** `client.parse(query)`, and
|
|
11
|
+
the same on `InProcess`, `FakeClient` and `Testing::Router` — a typed module
|
|
12
|
+
bound to that schema, running on that object, without naming either. It sits
|
|
13
|
+
on top of the contract rather than in it: `Retry` wraps a client and holds no
|
|
14
|
+
schema, so it has no `parse`, and a bare schema class fills the client slot
|
|
15
|
+
without one. `load_queries!` is the same rule over a directory.
|
|
8
16
|
|
|
9
17
|
A *transport* is the network end of that contract — GraphQL-over-HTTP. The bundled
|
|
10
18
|
two — `Transport::HTTP` (net/http, zero dependencies, loaded by default)
|
|
@@ -22,31 +30,31 @@ Most apps need one line:
|
|
|
22
30
|
github = GraphWeaver.new("https://api.example.com/graphql", auth: ENV["API_TOKEN"])
|
|
23
31
|
```
|
|
24
32
|
|
|
25
|
-
`GraphWeaver.new` builds a [`Client`](real_world.md):
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
`GraphWeaver.new` builds a [`Client`](real_world.md): a transport with
|
|
34
|
+
auth applied (exposed as `client.transport`), the schema introspected
|
|
35
|
+
lazily, and `parse`/`run` bound to both. A `Client` answers the client
|
|
36
|
+
contract itself, so it goes anywhere a transport does — `Retry.new(client)`,
|
|
37
|
+
`subgraphs:`, a cassette recorder.
|
|
28
38
|
|
|
29
39
|
- `auth:` — a token; "Bearer" is assumed unless the string carries its own
|
|
30
40
|
scheme (`"Basic dXNlcjpwYXNz..."`)
|
|
41
|
+
- `transport:` — `:http` (the default) or `:faraday`
|
|
31
42
|
- `headers:` — anything else (API keys, custom headers)
|
|
32
43
|
- `retries:` — off by default; `true` for a `Retry` with defaults,
|
|
33
44
|
or a Hash of its options
|
|
45
|
+
- `open_timeout:` / `read_timeout:` — seconds, defaulting to 10 and 30 on
|
|
46
|
+
either transport
|
|
34
47
|
- `cache:` / `ttl:` — schema introspection caching (see
|
|
35
48
|
[real world](real_world.md)); url clients only — a schema source never
|
|
36
49
|
introspects, so passing them raises
|
|
37
50
|
- a block customizes the Faraday connection (Faraday only — raises without it)
|
|
38
51
|
|
|
52
|
+
What you pass is what you get; the client logs which transport it built at
|
|
53
|
+
`info`.
|
|
54
|
+
|
|
39
55
|
To wire generated modules that don't bake a client, make it the app's
|
|
40
56
|
default: `GraphWeaver.client = github`. Anything satisfying the execute
|
|
41
|
-
contract works there — testing's
|
|
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.
|
|
57
|
+
contract works there — testing's `graphql:` tag swaps in a client per example.
|
|
50
58
|
|
|
51
59
|
## Building blocks
|
|
52
60
|
|
|
@@ -54,16 +62,29 @@ The client is convenience, not the only door — construct and assign
|
|
|
54
62
|
yourself for full control:
|
|
55
63
|
|
|
56
64
|
```ruby
|
|
57
|
-
# zero-dependency Net::HTTP — persistent (keep-alive)
|
|
58
|
-
#
|
|
65
|
+
# zero-dependency Net::HTTP — a pool of persistent (keep-alive)
|
|
66
|
+
# connections; timeouts raise retriable TransportError
|
|
59
67
|
GraphWeaver::Transport::HTTP.new(
|
|
60
68
|
url,
|
|
61
69
|
headers: { ... },
|
|
62
70
|
open_timeout: 10, read_timeout: 30, # seconds (the defaults)
|
|
63
71
|
keep_alive_timeout: 2, # idle window before reconnecting
|
|
72
|
+
pool_size: 5, # concurrent requests in flight
|
|
73
|
+
# (default: RAILS_MAX_THREADS, else 5)
|
|
74
|
+
|
|
75
|
+
# TLS, forwarded to Net::HTTP.start — a private CA, or mTLS, without
|
|
76
|
+
# reaching for Faraday. Passing any of these to an http:// url raises
|
|
77
|
+
# rather than quietly doing nothing.
|
|
78
|
+
ca_file: "/etc/ssl/private-ca.pem", # or ca_path: for a directory
|
|
79
|
+
cert: OpenSSL::X509::Certificate.new(File.read("client.crt")),
|
|
80
|
+
key: OpenSSL::PKey::RSA.new(File.read("client.key")),
|
|
81
|
+
verify_mode: OpenSSL::SSL::VERIFY_PEER, # the default; VERIFY_NONE to skip
|
|
64
82
|
)
|
|
65
83
|
|
|
66
|
-
# Faraday: a url (+ optional middleware block), or a ready connection
|
|
84
|
+
# Faraday: a url (+ optional middleware block), or a ready connection.
|
|
85
|
+
# Timeouts default to the same 10/30 as Transport::HTTP — without them
|
|
86
|
+
# Faraday inherits net/http's 60s/60s.
|
|
87
|
+
GraphWeaver::Transport::Faraday.new(url, open_timeout: 10, read_timeout: 30)
|
|
67
88
|
GraphWeaver::Transport::Faraday.new(url) do |conn|
|
|
68
89
|
conn.request :authorization, "Bearer", -> { Tokens.fetch } # dynamic tokens
|
|
69
90
|
conn.response :logger
|
|
@@ -71,23 +92,83 @@ end
|
|
|
71
92
|
GraphWeaver::Transport::Faraday.new(MyApp.faraday_connection)
|
|
72
93
|
|
|
73
94
|
# One Faraday::Connection is reused for the transport's lifetime, but
|
|
74
|
-
# socket keep-alive depends on the ADAPTER
|
|
75
|
-
#
|
|
76
|
-
# (and real pooling), pick a persistent adapter:
|
|
95
|
+
# socket keep-alive depends on the ADAPTER (see below) — the transport
|
|
96
|
+
# logs the one it ended up with at :info:
|
|
77
97
|
GraphWeaver::Transport::Faraday.new(url) do |conn|
|
|
78
|
-
conn.adapter :net_http_persistent
|
|
98
|
+
conn.adapter :net_http_persistent
|
|
79
99
|
end
|
|
80
100
|
|
|
101
|
+
# In-process: a live graphql-ruby schema class, no socket — typed access
|
|
102
|
+
# to your own app's API. The class alone works in any client slot; the
|
|
103
|
+
# wrapper adds a request context, the same debug logging the network
|
|
104
|
+
# transports emit, and errors branded under GraphWeaver::Error (a resolver
|
|
105
|
+
# raise becomes a ServerError, status 500, with the original as #cause).
|
|
106
|
+
GraphWeaver::InProcess.new(MySchema, context: { current_user: user })
|
|
107
|
+
GraphWeaver.new(MySchema, context: { current_user: user }) # same, via a client
|
|
108
|
+
|
|
81
109
|
GraphWeaver.client = ... # the app default (a Client or any of the above)
|
|
82
110
|
```
|
|
83
111
|
|
|
112
|
+
**Keeping Faraday's sockets alive.** Faraday's default `net_http` adapter
|
|
113
|
+
opens a fresh connection per request — 10 TCP connections for 10 requests,
|
|
114
|
+
and over HTTPS a TLS handshake each time. `:net_http_persistent` is the
|
|
115
|
+
adapter that gets Faraday the connection reuse and thread-safe pooling
|
|
116
|
+
`Transport::HTTP` has by default. It needs two gems, and the version
|
|
117
|
+
pairing matters — **Faraday 2.x requires `faraday-net_http_persistent`
|
|
118
|
+
2.x**; the Faraday-1.x-era 1.2.0 raises `NoMethodError: undefined method
|
|
119
|
+
'dependency' for class Faraday::Adapter::NetHttpPersistent` at load:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
gem "net-http-persistent" # the HTTP client
|
|
123
|
+
gem "faraday-net_http_persistent", "~> 2.0" # the Faraday adapter for it
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
graph_weaver depends on neither and never selects an adapter for you.
|
|
127
|
+
|
|
128
|
+
**Headers.** Both transports send `Content-Type: application/json`,
|
|
129
|
+
`Accept: application/graphql-response+json, application/json;q=0.9` (the
|
|
130
|
+
media type [GraphQL-over-HTTP](https://graphql.github.io/graphql-over-http/draft/)
|
|
131
|
+
requires a conforming client to accept, with the legacy type as
|
|
132
|
+
fallback), and `User-Agent: graph_weaver/<version>` so a server operator
|
|
133
|
+
can attribute the traffic. Anything you pass in `headers:` wins over
|
|
134
|
+
these. A prebuilt `Faraday::Connection` owns its own headers; only the
|
|
135
|
+
ones it leaves unset are filled in.
|
|
136
|
+
|
|
137
|
+
**Request body.** `{"query": ..., "variables": ...}`, plus
|
|
138
|
+
`"operationName"` when the operation has a name — the field Apollo Studio,
|
|
139
|
+
Hasura and most APMs key traces, rate limits and slow-query reports on.
|
|
140
|
+
Generated modules always send one — an anonymous document is named after its
|
|
141
|
+
module at generation, so the name is declared in the query too. A raw query
|
|
142
|
+
string handed straight to a transport falls back to the name in the document,
|
|
143
|
+
and a genuinely anonymous one sends no `operationName` key at all.
|
|
144
|
+
|
|
145
|
+
**Concurrency.** One transport is normally the whole app's transport
|
|
146
|
+
(`GraphWeaver.client = api`), so it has to serve every thread.
|
|
147
|
+
`Transport::HTTP` opens up to `pool_size:` sockets lazily and reuses the
|
|
148
|
+
warmest one; requests beyond that queue for a free slot rather than
|
|
149
|
+
opening unbounded connections. A socket that errors is closed and its slot
|
|
150
|
+
left empty, so the next call reconnects.
|
|
151
|
+
|
|
152
|
+
`pool_size:` defaults to `RAILS_MAX_THREADS` (else 5) — the same variable
|
|
153
|
+
Rails sizes its own connection pool from, because it is the same question:
|
|
154
|
+
how many requests this process can have in flight at once. Lower it for a
|
|
155
|
+
server that counts connections.
|
|
156
|
+
|
|
157
|
+
Under a fiber scheduler (`async`, Falcon) everything here works unchanged —
|
|
158
|
+
`SizedQueue`, `Mutex`, `net/http` and `Kernel#sleep` are all scheduler-aware,
|
|
159
|
+
so requests multiplex on one thread at thread-equivalent throughput. But
|
|
160
|
+
`pool_size:` is the same hard ceiling there, and nothing sets
|
|
161
|
+
`RAILS_MAX_THREADS` for you, so set it to the concurrency you expect.
|
|
162
|
+
Saturation is not silent: the first request that has to queue logs a warning
|
|
163
|
+
naming how long it waited and what to raise.
|
|
164
|
+
|
|
84
165
|
## Client resolution
|
|
85
166
|
|
|
86
167
|
The canonical order — how a generated module finds its client (each slot
|
|
87
168
|
takes a `Client` or any bare transport/fake):
|
|
88
169
|
|
|
89
|
-
1. per call: `execute(some_client, ...)` —
|
|
90
|
-
|
|
170
|
+
1. per call: `execute(client: some_client, ...)` — a kwarg like the
|
|
171
|
+
variables, and a name no GraphQL variable is allowed to take
|
|
91
172
|
2. per module: `MyQuery.client = something`
|
|
92
173
|
3. baked constant: `Codegen.generate(..., client: MyApi::CLIENT)`
|
|
93
174
|
4. the app default: `GraphWeaver.client=`
|
|
@@ -112,11 +193,29 @@ GraphWeaver::Retry.new(
|
|
|
112
193
|
```
|
|
113
194
|
|
|
114
195
|
Defaults: transport failures always retry (the request never arrived);
|
|
115
|
-
`ServerError`
|
|
116
|
-
fix it. `retry_codes:` re-inspects response
|
|
117
|
-
throttling can retry too (off by default —
|
|
118
|
-
Exhausting `tries:` re-raises the last
|
|
119
|
-
code-matched response).
|
|
196
|
+
`ServerError` on 5xx plus **408 and 429** — the rest of 4xx is a bug in
|
|
197
|
+
the request, retrying won't fix it. `retry_codes:` re-inspects response
|
|
198
|
+
envelopes so GraphQL-level throttling can retry too (off by default —
|
|
199
|
+
pass the codes your API uses). Exhausting `tries:` re-raises the last
|
|
200
|
+
error (or returns the last code-matched response).
|
|
201
|
+
|
|
202
|
+
**`Retry-After` wins over the backoff.** When the server names a delay
|
|
203
|
+
(seconds or an HTTP-date), that's the wait — the server is the only
|
|
204
|
+
party that knows when its window reopens. It's clamped to `max:` so a
|
|
205
|
+
"come back in an hour" can't park a thread for an hour, and not
|
|
206
|
+
jittered, since it's an instruction rather than a guess.
|
|
207
|
+
|
|
208
|
+
`ServerError` carries the response `#headers` (names downcased), so the
|
|
209
|
+
rate-limit budget and request id are in hand without monkey-patching a
|
|
210
|
+
transport:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
rescue GraphWeaver::ServerError => e
|
|
214
|
+
e.throttled? # 429, or 503 + Retry-After
|
|
215
|
+
e.retry_after # seconds, or nil
|
|
216
|
+
e.headers["x-ratelimit-remaining"]
|
|
217
|
+
end
|
|
218
|
+
```
|
|
120
219
|
|
|
121
220
|
Or via the client: `GraphWeaver.new(url, retries: { tries: 5, retry_codes: ["THROTTLED"] })`.
|
|
122
221
|
|
data/docs/upgrading.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Upgrading to 0.5.0
|
|
2
|
+
|
|
3
|
+
0.5.0 is one large breaking release. Almost all of it is caught mechanically —
|
|
4
|
+
the work is running three commands and following what they tell you.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
rake graph_weaver:generate # 1. regenerate; the emitted call shape changed
|
|
8
|
+
srb tc # 2. every call site that moved is now a type error
|
|
9
|
+
rake graph_weaver:verify # 3. fails until the tree is regenerated
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Generated code is `# typed: strict`, so step 2 finds the call sites for you.
|
|
13
|
+
The rest of this page is what a typechecker can't see.
|
|
14
|
+
|
|
15
|
+
## `execute` means one thing now
|
|
16
|
+
|
|
17
|
+
Every client answers the same call — `execute(query, variables:, operation_name:)`,
|
|
18
|
+
returning the raw response hash. `Client` used to spell something else under
|
|
19
|
+
that name, which is why `Retry.new(client)` and `Sequence.new(client, fake)`
|
|
20
|
+
raised `ArgumentError`. They work now.
|
|
21
|
+
|
|
22
|
+
The one-shot sugar moved to `run`:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
client.execute!("query { … }", id: "1") # before
|
|
26
|
+
client.run!("query { … }", id: "1") # after (and #run for the envelope)
|
|
27
|
+
|
|
28
|
+
GraphWeaver.execute(source, query, **vars) # before
|
|
29
|
+
GraphWeaver.run(source, query, **vars) # after
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**This one is worth grepping for.** `Client#execute` still exists, so a stale
|
|
33
|
+
call fails at runtime rather than at typecheck: `rg '\.execute!?\(' --type ruby`
|
|
34
|
+
and check each hit is passing `variables:` rather than loose kwargs.
|
|
35
|
+
|
|
36
|
+
A generated module takes its per-call client as a **keyword**:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
PersonQuery.execute(some_client, id: "1") # before
|
|
40
|
+
PersonQuery.execute(client: some_client, id: "1") # after
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`GraphWeaver.resolve_transport` is gone; nothing needs unwrapping any more.
|
|
44
|
+
|
|
45
|
+
## Path settings are lists
|
|
46
|
+
|
|
47
|
+
`queries_paths`, `generated_paths`, `fragments_paths` — every entry is read.
|
|
48
|
+
Assigning a String still works, so the change is the name:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
GraphWeaver.queries_path = "app/graphql/queries" # before
|
|
52
|
+
GraphWeaver.queries_paths = "app/graphql/queries" # after
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`schema_path` stays singular: one run reads one schema.
|
|
56
|
+
|
|
57
|
+
## One reset
|
|
58
|
+
|
|
59
|
+
`GraphWeaver.reset_registrations!` is the clean slate between tests. The four
|
|
60
|
+
narrow ones moved to where they live:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
GraphWeaver.reset_scalars! # before
|
|
64
|
+
GraphWeaver::Codegen.reset_scalars! # after (also reset_enums!, clear_scalars!,
|
|
65
|
+
# reset_type_helpers!)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Smaller renames
|
|
69
|
+
|
|
70
|
+
| before | after |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `response.ok?` | `response.success?` |
|
|
73
|
+
| `Testing.config.auto_fake = true` | `Testing.config.default_mode = :fake` |
|
|
74
|
+
| `register_scalar(…, coerce: :to_s)` | `coerce: true`, or a `cast:`/`serialize:` pair |
|
|
75
|
+
| a mutation's `…Query` module | `…Mutation` |
|
|
76
|
+
|
|
77
|
+
`Testing::LiveSchema` is gone. If your client points at a different API than the
|
|
78
|
+
schema class your specs run in-process, name it once:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
GraphWeaver::Testing.config.schema = MySchema
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Registering from Rails
|
|
85
|
+
|
|
86
|
+
A registration naming one of your own constants belongs in a `to_prepare` block
|
|
87
|
+
— the same place the in-process client goes, and for the same reason:
|
|
88
|
+
autoloading is set up after `config/initializers` run.
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
Rails.application.config.to_prepare do
|
|
92
|
+
GraphWeaver.register_enum("Species", PetKind, fallback: PetKind::Unknown)
|
|
93
|
+
GraphWeaver.extend_type("Pet", PetHelpers)
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Generation depends on `:environment`, which runs `to_prepare` too, so the
|
|
98
|
+
registration is in place before it emits.
|
|
99
|
+
|
|
100
|
+
## If you use the federation router
|
|
101
|
+
|
|
102
|
+
Detection only sees *loaded* schema classes, and Rails does not eager load for
|
|
103
|
+
rake or in the default test environment. Both are one line:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
config.eager_load = true # config/environments/test.rb
|
|
107
|
+
config.rake_eager_load = true # config/application.rb
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Without them the `federation:*` tasks silently see nothing — and
|
|
111
|
+
`federation:diff` now **fails** rather than reporting a green "matches" over
|
|
112
|
+
zero subgraphs.
|
data/graph_weaver.gemspec
CHANGED
|
@@ -2,7 +2,9 @@ require_relative "lib/graph_weaver/version"
|
|
|
2
2
|
|
|
3
3
|
Gem::Specification.new do |s|
|
|
4
4
|
s.authors = ["Daniel Pepper"]
|
|
5
|
-
|
|
5
|
+
# the README tagline, verbatim — the two pitches drifted apart once already,
|
|
6
|
+
# so spec/gemspec_spec.rb pins them together
|
|
7
|
+
s.description = "Your .graphql files, compiled into Sorbet types — and the fakes to test them."
|
|
6
8
|
# ".yardopts" explicitly: `git ls-files *` skips dotfiles, and
|
|
7
9
|
# rubydoc.info needs it shipped to render docstrings as markdown
|
|
8
10
|
s.files = `git ls-files * ':!:spec' ':!:sorbet' ':!:bin' ':!:examples'`.split("\n") + [".yardopts"]
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# typed: ignore — Rails::Generators DSL, only loaded by `rails g`
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "graph_weaver"
|
|
5
|
+
|
|
6
|
+
# rails g graph_weaver:install https://api.example.com/graphql
|
|
7
|
+
# rails g graph_weaver:install MyApp::Schema
|
|
8
|
+
# rails g graph_weaver:install db/schema.graphql
|
|
9
|
+
#
|
|
10
|
+
# Scaffolds the conventional layout — initializer, query/generated
|
|
11
|
+
# directories, editor config — and bootstraps the schema dump, so the
|
|
12
|
+
# setup in docs/getting_started.md is one command.
|
|
13
|
+
#
|
|
14
|
+
# The argument is what you'd pass to GraphWeaver.new, and the same three
|
|
15
|
+
# source forms are accepted; the initializer it writes reflects the one
|
|
16
|
+
# you chose. The source arrives on the command line rather than being read
|
|
17
|
+
# from config: at install time the initializer doesn't exist yet.
|
|
18
|
+
module GraphWeaver
|
|
19
|
+
module Generators
|
|
20
|
+
class InstallGenerator < Rails::Generators::Base
|
|
21
|
+
desc <<~TEXT
|
|
22
|
+
Wire up GraphWeaver: initializer, app/graphql layout, editor config, schema dump.
|
|
23
|
+
|
|
24
|
+
SOURCE is what you'd pass to GraphWeaver.new:
|
|
25
|
+
|
|
26
|
+
rails g graph_weaver:install https://api.example.com/graphql # an endpoint
|
|
27
|
+
rails g graph_weaver:install MyApp::Schema # a graphql-ruby schema, in-process
|
|
28
|
+
rails g graph_weaver:install db/schema.graphql # a schema dump you already have
|
|
29
|
+
TEXT
|
|
30
|
+
|
|
31
|
+
argument :source, type: :string, banner: "SOURCE",
|
|
32
|
+
desc: "what you'd pass to GraphWeaver.new: an endpoint url, a graphql-ruby schema class, or a schema dump path"
|
|
33
|
+
|
|
34
|
+
class_option :auth, type: :string,
|
|
35
|
+
desc: "name of the ENV var holding the auth token (url only) — default GRAPHWEAVER_AUTH"
|
|
36
|
+
class_option :schema, type: :boolean, default: true,
|
|
37
|
+
desc: "write the schema dump codegen reads"
|
|
38
|
+
|
|
39
|
+
# a Ruby constant path names a schema class; anything that is neither
|
|
40
|
+
# this nor a url is taken as a path to a dump
|
|
41
|
+
CONSTANT = /\A[A-Z]\w*(::[A-Z]\w*)*\z/
|
|
42
|
+
|
|
43
|
+
DEFAULT_AUTH = "GRAPHWEAVER_AUTH"
|
|
44
|
+
|
|
45
|
+
# Before anything is written: a mistyped source or a flag that doesn't
|
|
46
|
+
# apply to it is a mistake in the command just typed, so say so there
|
|
47
|
+
# rather than at boot, three files later.
|
|
48
|
+
def check_source
|
|
49
|
+
if options[:auth] && form != :url
|
|
50
|
+
raise Thor::Error, "--auth applies to a url — #{source} is a #{form == :schema_class ? "schema class" : "schema dump"}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
schema_class if form == :schema_class
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Every write goes through create_file, so a re-run prompts with a
|
|
57
|
+
# diff rather than overwriting an initializer you've edited.
|
|
58
|
+
def create_initializer
|
|
59
|
+
create_file "config/initializers/graph_weaver.rb", initializer
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def create_layout
|
|
63
|
+
create_file File.join(GraphWeaver.queries_paths.first, ".keep"), ""
|
|
64
|
+
create_file File.join(GraphWeaver.generated_paths.first, ".keep"), ""
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# editor autocomplete + validation for .graphql files (docs/editors.md)
|
|
68
|
+
def create_editor_config
|
|
69
|
+
create_file "graphql.config.yml", editor_config
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A url is introspected and a schema class dumped; a dump the app
|
|
73
|
+
# already has is left where it is (schema_path points at it instead).
|
|
74
|
+
def fetch_schema
|
|
75
|
+
return unless options[:schema] && form != :path
|
|
76
|
+
|
|
77
|
+
if form == :url
|
|
78
|
+
# pass the var name, not just the token — it lands in the dump's
|
|
79
|
+
# provenance so schema:refresh/:diff read the same one the
|
|
80
|
+
# initializer does, instead of defaulting to GRAPHWEAVER_AUTH
|
|
81
|
+
GraphWeaver::SchemaLoader.refresh!(url: source, auth_env: auth_var)
|
|
82
|
+
else
|
|
83
|
+
# a schema class is its own introspection source; ttl: 0 so an
|
|
84
|
+
# existing dump never counts as fresh
|
|
85
|
+
GraphWeaver::SchemaLoader.introspect(schema_class, cache: schema_path, ttl: 0)
|
|
86
|
+
end
|
|
87
|
+
say_status :introspect, "#{schema_path} from #{source}"
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
# the files above are the valuable part — don't lose them to a bad
|
|
90
|
+
# token or an unreachable host
|
|
91
|
+
say_status :failed, "#{e.message} — retry with `#{refresh_command}`", :red
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def next_steps
|
|
95
|
+
say <<~TEXT
|
|
96
|
+
|
|
97
|
+
Write a query in #{GraphWeaver.queries_paths.first}, then:
|
|
98
|
+
|
|
99
|
+
rake graph_weaver:generate
|
|
100
|
+
|
|
101
|
+
Docs: https://github.com/dpep/graph_weaver/blob/main/docs/getting_started.md
|
|
102
|
+
TEXT
|
|
103
|
+
|
|
104
|
+
say federated_steps if subgraphs
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# This install run is the one moment the user is guaranteed to be
|
|
110
|
+
# reading, and a composed supergraph changes what the next steps are:
|
|
111
|
+
# the test client is the interesting one, and there's a CI gate to add.
|
|
112
|
+
def federated_steps
|
|
113
|
+
<<~TEXT
|
|
114
|
+
|
|
115
|
+
#{source} is a composed supergraph (#{subgraphs.size} subgraphs: #{subgraphs.join(", ")}), so:
|
|
116
|
+
|
|
117
|
+
rake graph_weaver:federation:diff # CI gate: a subgraph changed, nobody recomposed
|
|
118
|
+
rake graph_weaver:federation:subgraphs # which schema here serves which subgraph
|
|
119
|
+
|
|
120
|
+
and specs run against your real resolvers across all of them, in-process:
|
|
121
|
+
|
|
122
|
+
describe "checkout", graphql: :router do ... end # require "graph_weaver/rspec"
|
|
123
|
+
|
|
124
|
+
Docs: https://github.com/dpep/graph_weaver/blob/main/docs/federation.md
|
|
125
|
+
TEXT
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The subgraph names this source composes, or nil when it isn't a
|
|
129
|
+
# composed supergraph. Read off the routing table rather than guessed —
|
|
130
|
+
# the same reader Testing::Router and federation:diff use.
|
|
131
|
+
def subgraphs
|
|
132
|
+
return @subgraphs if defined?(@subgraphs)
|
|
133
|
+
|
|
134
|
+
@subgraphs =
|
|
135
|
+
begin
|
|
136
|
+
(GraphWeaver::SchemaLoader.routing_table(source).subgraphs if form == :path)
|
|
137
|
+
rescue StandardError
|
|
138
|
+
# not a supergraph, or not readable — nothing to say either way
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Which of GraphWeaver.new's source forms this is — the url test is
|
|
144
|
+
# its own, so the generator and the client can't disagree about what
|
|
145
|
+
# counts as one.
|
|
146
|
+
def form
|
|
147
|
+
@form ||=
|
|
148
|
+
if source.match?(GraphWeaver::Client::URL)
|
|
149
|
+
:url
|
|
150
|
+
elsif source.match?(CONSTANT)
|
|
151
|
+
:schema_class
|
|
152
|
+
else
|
|
153
|
+
:path
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The named schema class, resolved now: `rails g` boots the app, so a
|
|
158
|
+
# typo is catchable here rather than as a NameError at the next boot.
|
|
159
|
+
def schema_class
|
|
160
|
+
@schema_class ||= begin
|
|
161
|
+
klass = Object.const_get(source)
|
|
162
|
+
unless klass.respond_to?(:execute)
|
|
163
|
+
raise Thor::Error, "#{source} isn't a graphql-ruby schema (no .execute) — pass the class that inherits GraphQL::Schema"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
klass
|
|
167
|
+
rescue NameError
|
|
168
|
+
raise Thor::Error, "uninitialized constant #{source} — pass your graphql-ruby schema class " \
|
|
169
|
+
"(rails g graphql:install writes app/graphql/<app>_schema.rb), an endpoint url, or a path to a schema dump"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# A dump the app already has stays where it is; every other form
|
|
174
|
+
# writes the conventional one.
|
|
175
|
+
def schema_path = (form == :path) ? source : GraphWeaver.schema_path
|
|
176
|
+
|
|
177
|
+
def auth_var = options[:auth] || DEFAULT_AUTH
|
|
178
|
+
|
|
179
|
+
def refresh_command
|
|
180
|
+
(form == :url) ? "rake graph_weaver:schema:refresh" : "rails g graph_weaver:install #{source}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def initializer
|
|
184
|
+
<<~RUBY
|
|
185
|
+
# frozen_string_literal: true
|
|
186
|
+
|
|
187
|
+
#{client_setup}
|
|
188
|
+
# Custom scalars, enums and type mixins go here — `rake graph_weaver:generate`
|
|
189
|
+
# bakes them into the generated source, so they must be registered first:
|
|
190
|
+
#
|
|
191
|
+
# GraphWeaver.register_scalar("DateTime", Time, serialize: :iso8601, requires: "time")
|
|
192
|
+
# GraphWeaver.extend_type("Person", Greetable)
|
|
193
|
+
RUBY
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The load-bearing lines, per source form: what generated modules
|
|
197
|
+
# resolve to at execute time, and where codegen reads the schema.
|
|
198
|
+
def client_setup
|
|
199
|
+
case form
|
|
200
|
+
when :url
|
|
201
|
+
<<~RUBY
|
|
202
|
+
GraphWeaver.client = GraphWeaver.new(
|
|
203
|
+
"#{source}",
|
|
204
|
+
auth: ENV["#{auth_var}"],
|
|
205
|
+
cache: true, # reuse the committed dump; delete it to re-introspect
|
|
206
|
+
)
|
|
207
|
+
RUBY
|
|
208
|
+
when :schema_class
|
|
209
|
+
# to_prepare, not a bare assignment: the schema class is autoloaded,
|
|
210
|
+
# so it isn't resolvable this early, and a dev reload replaces it
|
|
211
|
+
# with a new class object the client would otherwise still hold.
|
|
212
|
+
<<~RUBY
|
|
213
|
+
Rails.application.config.to_prepare do
|
|
214
|
+
# queries run in-process against the app's own schema — no socket
|
|
215
|
+
GraphWeaver.client = GraphWeaver.new(#{source})
|
|
216
|
+
end
|
|
217
|
+
RUBY
|
|
218
|
+
when :path
|
|
219
|
+
if subgraphs
|
|
220
|
+
<<~RUBY
|
|
221
|
+
GraphWeaver.schema_path = "#{source}"
|
|
222
|
+
|
|
223
|
+
# A composed supergraph: your queries are generated against the whole
|
|
224
|
+
# graph, and a router serves it. Point the app default at the gateway:
|
|
225
|
+
#
|
|
226
|
+
# GraphWeaver.client = GraphWeaver.new("https://gateway.example.com/graphql")
|
|
227
|
+
#
|
|
228
|
+
# Specs don't need one — `graphql: :router` plans against this
|
|
229
|
+
# supergraph and runs your own subgraph resolvers in-process
|
|
230
|
+
# (docs/federation.md).
|
|
231
|
+
RUBY
|
|
232
|
+
else
|
|
233
|
+
<<~RUBY
|
|
234
|
+
GraphWeaver.schema_path = "#{source}"
|
|
235
|
+
|
|
236
|
+
# A dump is type information only — it has no resolvers, so it can't
|
|
237
|
+
# execute. Point the app default at whatever serves this API:
|
|
238
|
+
#
|
|
239
|
+
# GraphWeaver.client = GraphWeaver.new("https://api.example.com/graphql")
|
|
240
|
+
RUBY
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# fragments are in documents: too — without them an editor reports
|
|
246
|
+
# `Unknown fragment` on any query that spreads a shared one
|
|
247
|
+
def editor_config
|
|
248
|
+
<<~YAML
|
|
249
|
+
# Autocomplete and validation for .graphql files in VS Code / RubyMine.
|
|
250
|
+
# https://github.com/dpep/graph_weaver/blob/main/docs/editors.md
|
|
251
|
+
schema: #{schema_path}
|
|
252
|
+
documents:
|
|
253
|
+
- #{GraphWeaver.queries_paths.first}/**/*.{graphql,gql}
|
|
254
|
+
- #{GraphWeaver.fragments_paths.first}/**/*.{graphql,gql}
|
|
255
|
+
YAML
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|