graph_weaver 0.5.0 → 0.6.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 +537 -0
- data/Gemfile.lock +19 -19
- data/README.md +74 -53
- data/docs/cassettes.md +29 -4
- data/docs/editors.md +3 -1
- data/docs/errors.md +75 -16
- data/docs/federation.md +206 -155
- data/docs/generated_modules.md +223 -166
- data/docs/getting_started.md +106 -82
- data/docs/logging.md +35 -5
- data/docs/scalars.md +119 -24
- data/docs/testing.md +196 -155
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +243 -22
- data/graph_weaver.gemspec +16 -2
- data/lib/generators/graph_weaver/install_generator.rb +31 -16
- data/lib/graph_weaver/client.rb +52 -15
- data/lib/graph_weaver/codegen/aliases.rb +15 -8
- data/lib/graph_weaver/codegen/emit.rb +107 -42
- data/lib/graph_weaver/codegen/enum_type.rb +4 -3
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
- data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
- data/lib/graph_weaver/codegen.rb +382 -105
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +57 -13
- data/lib/graph_weaver/federation.rb +10 -22
- data/lib/graph_weaver/hints.rb +76 -2
- data/lib/graph_weaver/in_process.rb +11 -8
- data/lib/graph_weaver/inflect.rb +2 -0
- data/lib/graph_weaver/input_struct.rb +115 -12
- data/lib/graph_weaver/internal/overrides.rb +101 -0
- data/lib/graph_weaver/internal/planner.rb +868 -0
- data/lib/graph_weaver/internal/schemas.rb +50 -0
- data/lib/graph_weaver/internal/selection.rb +127 -0
- data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
- data/lib/graph_weaver/internal/values.rb +181 -0
- data/lib/graph_weaver/internal.rb +206 -0
- data/lib/graph_weaver/logging.rb +108 -20
- data/lib/graph_weaver/parsing.rb +6 -13
- data/lib/graph_weaver/query_module.rb +2 -0
- data/lib/graph_weaver/railtie.rb +113 -14
- data/lib/graph_weaver/representation.rb +30 -2
- data/lib/graph_weaver/response.rb +15 -0
- data/lib/graph_weaver/retry.rb +54 -22
- data/lib/graph_weaver/rspec.rb +63 -18
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +126 -35
- data/lib/graph_weaver/tasks.rb +88 -36
- data/lib/graph_weaver/testing/cassette.rb +131 -78
- data/lib/graph_weaver/testing/coverage.rb +11 -15
- data/lib/graph_weaver/testing/failure.rb +14 -8
- data/lib/graph_weaver/testing/fake_client.rb +253 -60
- data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
- data/lib/graph_weaver/testing/router.rb +147 -840
- data/lib/graph_weaver/testing.rb +40 -83
- data/lib/graph_weaver/transport/faraday.rb +1 -1
- data/lib/graph_weaver/transport/http.rb +29 -12
- data/lib/graph_weaver/transport.rb +11 -34
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +221 -118
- metadata +17 -13
- data/CLAUDE.md +0 -161
- data/DECISIONS.md +0 -309
- data/Makefile +0 -23
- data/NOTES.md +0 -182
- data/PLAN.md +0 -115
- data/REVIEW.md +0 -946
- data/lib/graph_weaver/schemas.rb +0 -46
- data/lib/graph_weaver/selection.rb +0 -120
- data/lib/graph_weaver/testing/values.rb +0 -98
data/docs/upgrading.md
CHANGED
|
@@ -1,18 +1,199 @@
|
|
|
1
|
-
# Upgrading
|
|
1
|
+
# Upgrading
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
## Regenerate on every upgrade
|
|
4
|
+
|
|
5
|
+
**Any release can change what codegen emits.** Patch releases included — most of
|
|
6
|
+
them are fixes to a generated type, and a fix to a type is a change to the bytes.
|
|
7
|
+
0.5.1 was a patch and moved three of them.
|
|
8
|
+
|
|
9
|
+
So `rake graph_weaver:generate` is part of upgrading the gem, every time, and
|
|
10
|
+
`rake graph_weaver:verify` is the detector: it fails when the checked-in Ruby
|
|
11
|
+
isn't what this version would write. Nothing beyond that is promised — there is
|
|
12
|
+
no "generated output is stable within a minor" rule to lean on. What each release
|
|
13
|
+
changed, and whether it needs a regenerate, is in the changelog.
|
|
14
|
+
|
|
15
|
+
A generated file's header names the release that wrote it, so the first `verify`
|
|
16
|
+
after an upgrade reports the tree as stale whether or not codegen actually
|
|
17
|
+
moved. That's the reminder working, not a false alarm.
|
|
18
|
+
|
|
19
|
+
Generation is deterministic, so the diff is exactly what the new version emits
|
|
20
|
+
differently and nothing else — worth reading rather than rubber-stamping.
|
|
21
|
+
|
|
22
|
+
## Upgrading from 0.5.1
|
|
23
|
+
|
|
24
|
+
Much smaller than 0.5.0, and mostly mechanical. Three commands find most of it:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
rake graph_weaver:generate # 1. what codegen emits moved in several places
|
|
28
|
+
srb tc # 2. kwargs that got narrower are call-site errors
|
|
29
|
+
bundle exec rspec # 3. every deleted knob raises where it's still set
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The rest of this section is what those three don't catch.
|
|
33
|
+
|
|
34
|
+
### Loose input coerces, so `coerce:` and `auto_coerce` are gone
|
|
35
|
+
|
|
36
|
+
`execute(first: params[:first])` converts the String to an `Integer` — for every
|
|
37
|
+
variable and every input-object field, with nothing to switch on. The old way of
|
|
38
|
+
buying that was `GraphWeaver.auto_coerce` or `register_scalar(…, coerce: true)`,
|
|
39
|
+
and both paid for it by **widening the emitted kwarg**, which switched off the
|
|
40
|
+
static check at every call site. Delete them:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
GraphWeaver.auto_coerce = true # gone
|
|
44
|
+
GraphWeaver.register_scalar("Money", Money, coerce: true) # drop the coerce:
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Behavior is unchanged; the kwarg is not. It is now typed exactly as the schema
|
|
48
|
+
types it, so a call site passing a **literal** of the wrong type is a new
|
|
49
|
+
`srb tc` error — which is the point, since a literal is one you can just spell
|
|
50
|
+
right:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
StargazersQuery.execute(first: "10") # srb tc error now
|
|
54
|
+
StargazersQuery.execute(first: params[:first]) # fine, and "10" becomes 10
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`cast:` is what a loose value converts through, so a custom scalar needs nothing
|
|
58
|
+
beyond the registration it already has. Bad input raises
|
|
59
|
+
`GraphWeaver::InputError` naming the variable, the operation and the value.
|
|
60
|
+
|
|
61
|
+
Two conversions got **stricter** at the same time, and either can bite an app
|
|
62
|
+
that was passing. A numeric string is now read as a wire format rather than as
|
|
63
|
+
Ruby source, so `"010"` is ten rather than eight and `"0x1f"` and `"1_0"` are
|
|
64
|
+
refused. And a `Boolean` refuses a String outright — every rule for `"0"` and
|
|
65
|
+
`"off"` is somebody's convention, so convert at the call site.
|
|
66
|
+
|
|
67
|
+
### `nil` sends `null`
|
|
68
|
+
|
|
69
|
+
A variable passed `nil` now sends an explicit `null`; one left out is still left
|
|
70
|
+
out. That's what lets a mutation clear a field — and it changes what a kwarg fed
|
|
71
|
+
a possibly-missing value means:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
UpdateProfile.execute!(bio: params[:bio]) # a missing param used to omit; now it clears the bio
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Grep for kwargs fed straight from `params` or an optional attribute**, and
|
|
78
|
+
pass the keyword only when you mean it:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
UpdateProfile.execute!(**(params[:bio] ? { bio: params[:bio] } : {}))
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Non-null variables are unaffected: they can't carry `null`, so `nil` there still
|
|
85
|
+
omits and the schema default applies. Input objects get the distinction only
|
|
86
|
+
where a Hash can express it — `coerce({nickname: nil})` sends null, `coerce({})`
|
|
87
|
+
omits, and a struct built with `.new` can't tell the two apart, so `nil` there
|
|
88
|
+
still means omit.
|
|
89
|
+
|
|
90
|
+
### Renames
|
|
91
|
+
|
|
92
|
+
| before | after |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `Retry.new(tries: n)`, `retries: { tries: n }` | `retries: n - 1` — one word everywhere, counting the attempts *after* the first, so `retries: 0` is one attempt and `GraphWeaver.new(url, retries: 3)` is four |
|
|
95
|
+
| `GraphWeaver.new(url, retries: { retries: 5, retry_codes: […] })` | `GraphWeaver.new(url, retries: 5, retry_codes: […])` — the other retry options sit beside the count; the Hash form read as a key nested in itself |
|
|
96
|
+
| `Retry.new(t, on: […])` | `Retry.new(t, retry_on: […])` |
|
|
97
|
+
| `Retry.new(t, base: 0.5, max: 30)` | `Retry.new(t, base_delay: 0.5, max_delay: 30)` — beside a count, `max: 30` read as a second, larger attempt count |
|
|
98
|
+
| `Codegen.generate(module_name:)` | `name:` — the spelling `GraphWeaver.parse` already used; `module_name:` now raises, naming its replacement |
|
|
99
|
+
| `Testing.config.null_chance = 0.3` | `graphql_fake(null_chance: 0.3)`, on the example that wants it |
|
|
100
|
+
| `Testing.config.mode = :literal` | `graphql_fake(values: :literal)`, likewise |
|
|
101
|
+
| `Testing::MODES` | `Testing::VALUE_STYLES` |
|
|
102
|
+
| `SchemaLoader.stale?(path)` | `SchemaLoader.diff(path).empty?` — and `diff` also names what moved |
|
|
103
|
+
|
|
104
|
+
The two `Testing.config` deletions are the ones worth a sentence. A suite-wide
|
|
105
|
+
`null_chance` answers a per-example question, so it nils an unrelated field one
|
|
106
|
+
run in ten, on a seed the failure doesn't name; move it onto the examples that
|
|
107
|
+
are *about* an empty state. (`config.default_mode` and the `graphql: :fake` tag
|
|
108
|
+
are untouched — the per-fake `mode:` became `values:` so the two can't be
|
|
109
|
+
confused for each other.) Every retry misspelling raises rather than being
|
|
110
|
+
ignored: the Hash form names its flat replacement, and a retry option passed
|
|
111
|
+
without `retries:` says so.
|
|
112
|
+
|
|
113
|
+
### The internals moved behind `Internal`
|
|
114
|
+
|
|
115
|
+
The public surface is now what the docs name, what generated code calls, and the
|
|
116
|
+
`execute` slot; everything else sits under `GraphWeaver::Internal` or went
|
|
117
|
+
`private`, and a spec diffs the two so the next accidental promotion fails CI.
|
|
118
|
+
Nothing documented moved — skip this section unless `srb tc` or a
|
|
119
|
+
`NoMethodError` says otherwise.
|
|
120
|
+
|
|
121
|
+
What a suite might plausibly have reached for: the federation query planner and
|
|
122
|
+
its IR (`Internal::Planner`), the fake-value engine (`Internal::Values`), the
|
|
123
|
+
selection walk (`Internal::Selection` — so `FakeClient` no longer answers
|
|
124
|
+
`each_field` or `gather`), the cassette matching rules (`Internal::RequestKey`),
|
|
125
|
+
subgraph detection (was `Testing::Subgraphs`), `GraphWeaver.log` /
|
|
126
|
+
`.instrument` / `.filter_variables` (`Internal::Log` — `logger=`,
|
|
127
|
+
`instrumenter=` and `filter_parameters=` are unchanged), and
|
|
128
|
+
`Transport.operation_name` / `.mutation?` / `.log_tag`, which left the class you
|
|
129
|
+
subclass for `Internal::Wire`.
|
|
130
|
+
|
|
131
|
+
Two smaller edges. `SchemaDiff::Change`, `Cassette::Check`, `Coverage::Result`
|
|
132
|
+
and `InputStruct::Field` are `Data` now rather than `Struct`, so they hand out
|
|
133
|
+
no writers — read one, build a new one to change a field. And generated modules
|
|
134
|
+
keep their own plumbing to themselves: `DEFAULT_CLIENT`, `FIELDS` and `ONE_OF`
|
|
135
|
+
are emitted `private_constant`, so **regenerate**.
|
|
136
|
+
|
|
137
|
+
### Behavior that changed under you
|
|
138
|
+
|
|
139
|
+
- **A mutation is no longer retried.** A timeout doesn't say whether the server
|
|
140
|
+
applied it, and a second `charge` is worse than a failed one. Pass
|
|
141
|
+
`retry_mutations: true` for an API whose mutations are idempotent.
|
|
142
|
+
- **A registration this schema can't match warns instead of failing
|
|
143
|
+
generation.** One registry serves a whole federated graph, so a name the
|
|
144
|
+
schema in hand doesn't declare may belong to the subgraph next door — see
|
|
145
|
+
[federation](federation.md#generating-for-a-federated-graph). Your typo is now
|
|
146
|
+
in the list `rake graph_weaver:generate` prints after the files, so read it.
|
|
147
|
+
- **`verify_generated!` fails when it finds no query documents.** A mistyped
|
|
148
|
+
`queries_paths` used to leave a CI gate green forever.
|
|
149
|
+
- **The local router refuses a `@fromContext` argument** rather than fetching
|
|
150
|
+
the field with it unset. Federation 2.8's `@context` machinery was on the
|
|
151
|
+
routing table's known list, so the argument was read and dropped. Per query,
|
|
152
|
+
like `@interfaceObject`: a subtree one subgraph answers whole still runs.
|
|
153
|
+
- **A `#trace` assertion may see one entry fewer.** Two `@requires` field sets
|
|
154
|
+
crossing into the same subgraph on the same `@key` now ride one entity fetch,
|
|
155
|
+
the way Apollo's do.
|
|
156
|
+
- **Fabricating a custom scalar registered as a class of your own needs a pin
|
|
157
|
+
for the type** — `Testing.config.overrides = { "Money" => "12.00" }`, or the
|
|
158
|
+
same key on one example's `graphql_fake`. Without one, `FakeClient` and
|
|
159
|
+
cassette anonymization refuse rather than feeding your cast a `"Money-1"`
|
|
160
|
+
placeholder. Scalars registered as `Time`, `Date`, `Integer`, `Float`,
|
|
161
|
+
`String` or `T::Boolean` need nothing.
|
|
162
|
+
- **Re-run `rake graph_weaver:cassettes:anonymize`** on any committed cassette
|
|
163
|
+
holding a registered custom scalar: the anonymizer used to write a value the
|
|
164
|
+
generated codec couldn't read back.
|
|
165
|
+
- **Generation refuses four more things**, each naming its fix — a
|
|
166
|
+
`register_scalar` whose Ruby type nothing can build out of JSON (`BigDecimal`,
|
|
167
|
+
classically: give it a `cast:`), a result key that would shadow a constant the
|
|
168
|
+
file uses, an enum value that camelizes to nothing, and a narrowed fragment
|
|
169
|
+
whose `__typename` sits behind `@skip`/`@include`.
|
|
170
|
+
|
|
171
|
+
## Upgrading to 0.5.0
|
|
172
|
+
|
|
173
|
+
0.5.0 is one large breaking release. Almost all of it is caught mechanically,
|
|
174
|
+
in this order:
|
|
5
175
|
|
|
6
176
|
```sh
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
177
|
+
# 1. rename the path settings first — generate won't load without them
|
|
178
|
+
# (queries_path -> queries_paths, generated_path -> generated_paths,
|
|
179
|
+
# fragments_path -> fragments_paths; see "Path settings are lists" below)
|
|
180
|
+
|
|
181
|
+
bundle exec tapioca gem graph_weaver # 2. regenerate the RBI
|
|
182
|
+
rake graph_weaver:generate # 3. the emitted call shape changed
|
|
183
|
+
srb tc # 4. every call site that moved is an error
|
|
184
|
+
rake graph_weaver:verify # 5. fails until the tree is regenerated
|
|
10
185
|
```
|
|
11
186
|
|
|
12
|
-
|
|
13
|
-
|
|
187
|
+
**Step 2 is not optional.** Against the 0.4.6 RBI, `srb tc` reports errors
|
|
188
|
+
pointing into your `generated/` directory — `QueryModule`, `client_for`,
|
|
189
|
+
`check_envelope!` — which read as though codegen emitted broken Ruby. It
|
|
190
|
+
didn't; sorbet is checking new generated code against the old gem's types.
|
|
191
|
+
Regenerate the RBI and what remains is only your own call sites.
|
|
192
|
+
|
|
193
|
+
Generated code is `# typed: strict`, so step 4 finds those for you. The rest of
|
|
194
|
+
this page is what a typechecker can't see.
|
|
14
195
|
|
|
15
|
-
|
|
196
|
+
### `execute` means one thing now
|
|
16
197
|
|
|
17
198
|
Every client answers the same call — `execute(query, variables:, operation_name:)`,
|
|
18
199
|
returning the raw response hash. `Client` used to spell something else under
|
|
@@ -30,7 +211,9 @@ GraphWeaver.run(source, query, **vars) # after
|
|
|
30
211
|
```
|
|
31
212
|
|
|
32
213
|
**This one is worth grepping for.** `Client#execute` still exists, so a stale
|
|
33
|
-
call fails at runtime rather than at typecheck
|
|
214
|
+
call fails at runtime rather than at typecheck — as do `GraphWeaver.execute`
|
|
215
|
+
and `GraphWeaver.reset_scalars!`, which are simply gone and will not be flagged
|
|
216
|
+
until the RBI is regenerated (step 2): `rg '\.execute!?\(' --type ruby`
|
|
34
217
|
and check each hit is passing `variables:` rather than loose kwargs.
|
|
35
218
|
|
|
36
219
|
A generated module takes its per-call client as a **keyword**:
|
|
@@ -42,7 +225,7 @@ PersonQuery.execute(client: some_client, id: "1") # after
|
|
|
42
225
|
|
|
43
226
|
`GraphWeaver.resolve_transport` is gone; nothing needs unwrapping any more.
|
|
44
227
|
|
|
45
|
-
|
|
228
|
+
### Path settings are lists
|
|
46
229
|
|
|
47
230
|
`queries_paths`, `generated_paths`, `fragments_paths` — every entry is read.
|
|
48
231
|
Assigning a String still works, so the change is the name:
|
|
@@ -54,9 +237,10 @@ GraphWeaver.queries_paths = "app/graphql/queries" # after
|
|
|
54
237
|
|
|
55
238
|
`schema_path` stays singular: one run reads one schema.
|
|
56
239
|
|
|
57
|
-
|
|
240
|
+
### One reset
|
|
58
241
|
|
|
59
|
-
`GraphWeaver.reset_registrations!` is the clean slate between tests
|
|
242
|
+
`GraphWeaver.reset_registrations!` is the clean slate between tests, or between
|
|
243
|
+
generations for different schemas. The four
|
|
60
244
|
narrow ones moved to where they live:
|
|
61
245
|
|
|
62
246
|
```ruby
|
|
@@ -65,23 +249,59 @@ GraphWeaver::Codegen.reset_scalars! # after (also reset_enums!, clear_scalar
|
|
|
65
249
|
# reset_type_helpers!)
|
|
66
250
|
```
|
|
67
251
|
|
|
68
|
-
|
|
252
|
+
### Generated names come from the response key, not the type
|
|
253
|
+
|
|
254
|
+
Nested structs used to be named for the GraphQL *type* they were cast from;
|
|
255
|
+
they are now named for the **response key that selects them**, camelized, and
|
|
256
|
+
the constant path reads like the query. The typechecker finds the call sites in
|
|
257
|
+
a `# typed: true` file (an unresolved constant is an `srb tc` error); in a
|
|
258
|
+
`# typed: false` file it is `uninitialized constant` at runtime, so grep for
|
|
259
|
+
`::Result::` there.
|
|
260
|
+
|
|
261
|
+
| selection | before (type) | after (key) |
|
|
262
|
+
|---|---|---|
|
|
263
|
+
| `person { pets { name } }` | `PersonQuery::Result::Person::Pet` | `PersonQuery::Result::Person::Pets` |
|
|
264
|
+
| `payrollRisk { score }` | `…::Result::RiskAssessment` | `…::Result::PayrollRisk` |
|
|
265
|
+
| `_entities(…) { ... on Product { … } }` | `…::Result::Product` | `…::Result::Entities::Product` |
|
|
266
|
+
|
|
267
|
+
The key is used verbatim — no pluralization, so a list field `pets` is `Pets`.
|
|
268
|
+
To pick the name yourself, alias the field: `pet: pets { name }` generates
|
|
269
|
+
`Pet`. Union and interface members keep their type-condition names, nested in
|
|
270
|
+
the container the field names. The payoff is that adding, removing or
|
|
271
|
+
reordering an unrelated selection can never rename a struct you reference.
|
|
272
|
+
|
|
273
|
+
**Enums moved out of the result tree.** Every schema enum a query touches is one
|
|
274
|
+
Ruby type in the shared module, `GraphQLTypes::Species`, so a value read from
|
|
275
|
+
one query hands straight into another's variable. A query module aliases the
|
|
276
|
+
enums its *variables* use (`AddPetMutation::Species` still works); an enum
|
|
277
|
+
reached only through a result is no longer nested under the struct that
|
|
278
|
+
carries it — `SearchQuery::Result::Search::Species` is `GraphQLTypes::Species`.
|
|
279
|
+
|
|
280
|
+
### Smaller renames
|
|
69
281
|
|
|
70
282
|
| before | after |
|
|
71
283
|
|---|---|
|
|
72
|
-
| `response.ok?` | `response.success?` |
|
|
73
284
|
| `Testing.config.auto_fake = true` | `Testing.config.default_mode = :fake` |
|
|
74
|
-
| `register_scalar(…, coerce: :to_s)` | `coerce: true`, or a `cast:`/`serialize:` pair |
|
|
75
285
|
| a mutation's `…Query` module | `…Mutation` |
|
|
286
|
+
| `graphql: :none` (rspec tag) | `graphql: false` |
|
|
287
|
+
|
|
288
|
+
**The shared types module was three, and is now one.** `GraphQLInputs`,
|
|
289
|
+
`GraphQLEnums` and `GraphQLUnions` are all `GraphQLTypes`, and the files move
|
|
290
|
+
with them — `generated/inputs/` becomes `generated/types/`. The three settings
|
|
291
|
+
that named them (`inputs_module=`, `enums_module=`, `unions_module=`) are one
|
|
292
|
+
`types_module=`. Regenerating writes the new tree; delete the old directory,
|
|
293
|
+
which pruning leaves behind empty.
|
|
76
294
|
|
|
77
|
-
|
|
78
|
-
|
|
295
|
+
If your specs run one schema class in-process while your client points at a
|
|
296
|
+
different API, name it — per example, since a federated suite runs more than
|
|
297
|
+
one:
|
|
79
298
|
|
|
80
299
|
```ruby
|
|
81
|
-
|
|
300
|
+
graphql_in_process(MySchema) # in the example
|
|
301
|
+
GraphWeaver::Testing.config.schema = MySchema # or once, for the whole suite
|
|
82
302
|
```
|
|
83
303
|
|
|
84
|
-
|
|
304
|
+
### Registering from Rails
|
|
85
305
|
|
|
86
306
|
A registration naming one of your own constants belongs in a `to_prepare` block
|
|
87
307
|
— the same place the in-process client goes, and for the same reason:
|
|
@@ -95,9 +315,10 @@ end
|
|
|
95
315
|
```
|
|
96
316
|
|
|
97
317
|
Generation depends on `:environment`, which runs `to_prepare` too, so the
|
|
98
|
-
registration is in place before it emits
|
|
318
|
+
registration is in place before it emits — and at boot the generated files
|
|
319
|
+
load from a `to_prepare` block of their own, after yours.
|
|
99
320
|
|
|
100
|
-
|
|
321
|
+
### If you use the federation router
|
|
101
322
|
|
|
102
323
|
Detection only sees *loaded* schema classes, and Rails does not eager load for
|
|
103
324
|
rake or in the default test environment. Both are one line:
|
data/graph_weaver.gemspec
CHANGED
|
@@ -7,13 +7,27 @@ Gem::Specification.new do |s|
|
|
|
7
7
|
s.description = "Your .graphql files, compiled into Sorbet types — and the fakes to test them."
|
|
8
8
|
# ".yardopts" explicitly: `git ls-files *` skips dotfiles, and
|
|
9
9
|
# rubydoc.info needs it shipped to render docstrings as markdown
|
|
10
|
-
|
|
10
|
+
# CLAUDE.md/PLAN.md/REVIEW.md/NOTES.md/DECISIONS.md are written for whoever
|
|
11
|
+
# works on the gem, not whoever installs it — and REVIEW.md carries examples
|
|
12
|
+
# from before the API it describes was rewritten
|
|
13
|
+
s.files = `git ls-files * ':!:spec' ':!:sorbet' ':!:bin' ':!:examples' \
|
|
14
|
+
':!:CLAUDE.md' ':!:PLAN.md' ':!:REVIEW.md' ':!:NOTES.md' \
|
|
15
|
+
':!:DECISIONS.md' ':!:Makefile' ':!:design'`.split("\n") + [".yardopts"]
|
|
11
16
|
s.homepage = "https://github.com/dpep/graph_weaver"
|
|
12
17
|
s.license = "MIT"
|
|
13
18
|
s.name = "graph_weaver"
|
|
14
|
-
|
|
19
|
+
# rubygems.org shows summary as the headline, description below it
|
|
20
|
+
s.summary = "A typed GraphQL client for Ruby"
|
|
15
21
|
s.version = GraphWeaver::VERSION
|
|
16
22
|
|
|
23
|
+
s.metadata = {
|
|
24
|
+
"bug_tracker_uri" => "#{s.homepage}/issues",
|
|
25
|
+
"changelog_uri" => "#{s.homepage}/blob/main/CHANGELOG.md",
|
|
26
|
+
"documentation_uri" => "#{s.homepage}/tree/main/docs",
|
|
27
|
+
"rubygems_mfa_required" => "true",
|
|
28
|
+
"source_code_uri" => s.homepage,
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
s.required_ruby_version = ">= 3.3"
|
|
18
32
|
|
|
19
33
|
# 2.6.7 fills defaulted directive arguments when building from SDL
|
|
@@ -31,17 +31,14 @@ module GraphWeaver
|
|
|
31
31
|
argument :source, type: :string, banner: "SOURCE",
|
|
32
32
|
desc: "what you'd pass to GraphWeaver.new: an endpoint url, a graphql-ruby schema class, or a schema dump path"
|
|
33
33
|
|
|
34
|
+
# the default is SchemaLoader's, not one restated here — an --auth the
|
|
35
|
+
# generator omits from the dump is one the schema tasks then can't find
|
|
34
36
|
class_option :auth, type: :string,
|
|
35
|
-
desc: "name of the ENV var holding the auth token (url only) —
|
|
37
|
+
desc: "name of the ENV var holding the auth token (url only) — " \
|
|
38
|
+
"default #{GraphWeaver::SchemaLoader::DEFAULT_AUTH_ENV}"
|
|
36
39
|
class_option :schema, type: :boolean, default: true,
|
|
37
40
|
desc: "write the schema dump codegen reads"
|
|
38
41
|
|
|
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
42
|
# Before anything is written: a mistyped source or a flag that doesn't
|
|
46
43
|
# apply to it is a mistake in the command just typed, so say so there
|
|
47
44
|
# rather than at boot, three files later.
|
|
@@ -59,8 +56,11 @@ module GraphWeaver
|
|
|
59
56
|
create_file "config/initializers/graph_weaver.rb", initializer
|
|
60
57
|
end
|
|
61
58
|
|
|
59
|
+
# fragments too: the editor config below globs it, and a shared fragment
|
|
60
|
+
# then has an obvious home rather than being invented later
|
|
62
61
|
def create_layout
|
|
63
62
|
create_file File.join(GraphWeaver.queries_paths.first, ".keep"), ""
|
|
63
|
+
create_file File.join(GraphWeaver.fragments_paths.first, ".keep"), ""
|
|
64
64
|
create_file File.join(GraphWeaver.generated_paths.first, ".keep"), ""
|
|
65
65
|
end
|
|
66
66
|
|
|
@@ -88,10 +88,18 @@ module GraphWeaver
|
|
|
88
88
|
rescue StandardError => e
|
|
89
89
|
# the files above are the valuable part — don't lose them to a bad
|
|
90
90
|
# token or an unreachable host
|
|
91
|
+
@schema_failed = true
|
|
91
92
|
say_status :failed, "#{e.message} — retry with `#{refresh_command}`", :red
|
|
92
93
|
end
|
|
93
94
|
|
|
94
95
|
def next_steps
|
|
96
|
+
# generation reads the dump, so without one the step below can't run —
|
|
97
|
+
# say that next to it rather than leaving the red line above to be
|
|
98
|
+
# scrolled past. A re-run that already has a dump is not blocked.
|
|
99
|
+
if @schema_failed && !GraphWeaver::SchemaLoader.locate_path
|
|
100
|
+
say "\nThere's no schema dump yet, so `rake graph_weaver:generate` has nothing to read."
|
|
101
|
+
end
|
|
102
|
+
|
|
95
103
|
say <<~TEXT
|
|
96
104
|
|
|
97
105
|
Write a query in #{GraphWeaver.queries_paths.first}, then:
|
|
@@ -140,14 +148,16 @@ module GraphWeaver
|
|
|
140
148
|
end
|
|
141
149
|
end
|
|
142
150
|
|
|
143
|
-
# Which of GraphWeaver.new's source forms this is
|
|
144
|
-
#
|
|
145
|
-
#
|
|
151
|
+
# Which of GraphWeaver.new's source forms this is. Neither test is its
|
|
152
|
+
# own — a url is whatever the client calls one, a constant path whatever
|
|
153
|
+
# codegen will spell — so the generator can't disagree with either about
|
|
154
|
+
# what it just wrote an initializer for. Anything that is neither is
|
|
155
|
+
# taken as a path to a dump.
|
|
146
156
|
def form
|
|
147
157
|
@form ||=
|
|
148
158
|
if source.match?(GraphWeaver::Client::URL)
|
|
149
159
|
:url
|
|
150
|
-
elsif source.match?(
|
|
160
|
+
elsif source.match?(GraphWeaver::Codegen::CONSTANT_NAME)
|
|
151
161
|
:schema_class
|
|
152
162
|
else
|
|
153
163
|
:path
|
|
@@ -174,10 +184,14 @@ module GraphWeaver
|
|
|
174
184
|
# writes the conventional one.
|
|
175
185
|
def schema_path = (form == :path) ? source : GraphWeaver.schema_path
|
|
176
186
|
|
|
177
|
-
def auth_var = options[:auth] ||
|
|
187
|
+
def auth_var = options[:auth] || GraphWeaver::SchemaLoader::DEFAULT_AUTH_ENV
|
|
178
188
|
|
|
189
|
+
# The command just typed, retyped. One rule for every source form, and
|
|
190
|
+
# the only one that always works: the files already written come back
|
|
191
|
+
# "identical", and --auth rides along — where schema:refresh has no flag
|
|
192
|
+
# for it, and with no dump written has no url to read either.
|
|
179
193
|
def refresh_command
|
|
180
|
-
|
|
194
|
+
"rails g graph_weaver:install #{source}#{" --auth #{options[:auth]}" if options[:auth]}"
|
|
181
195
|
end
|
|
182
196
|
|
|
183
197
|
def initializer
|
|
@@ -243,15 +257,16 @@ module GraphWeaver
|
|
|
243
257
|
end
|
|
244
258
|
|
|
245
259
|
# fragments are in documents: too — without them an editor reports
|
|
246
|
-
# `Unknown fragment` on any query that spreads a shared one
|
|
260
|
+
# `Unknown fragment` on any query that spreads a shared one. The glob is
|
|
261
|
+
# codegen's, so the editor validates exactly the files codegen reads.
|
|
247
262
|
def editor_config
|
|
248
263
|
<<~YAML
|
|
249
264
|
# Autocomplete and validation for .graphql files in VS Code / RubyMine.
|
|
250
265
|
# https://github.com/dpep/graph_weaver/blob/main/docs/editors.md
|
|
251
266
|
schema: #{schema_path}
|
|
252
267
|
documents:
|
|
253
|
-
- #{GraphWeaver.queries_paths.first
|
|
254
|
-
- #{GraphWeaver.fragments_paths.first
|
|
268
|
+
- #{File.join(GraphWeaver.queries_paths.first, GraphWeaver::Codegen::DOCUMENT_GLOB)}
|
|
269
|
+
- #{File.join(GraphWeaver.fragments_paths.first, GraphWeaver::Codegen::DOCUMENT_GLOB)}
|
|
255
270
|
YAML
|
|
256
271
|
end
|
|
257
272
|
end
|
data/lib/graph_weaver/client.rb
CHANGED
|
@@ -36,17 +36,32 @@ class GraphWeaver::Client
|
|
|
36
36
|
|
|
37
37
|
URL = %r{\Ahttps?://}i
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
# refused from two branches — a url source, and a schema source with
|
|
40
|
+
# nothing to hand a context to — so the two can't word it differently
|
|
41
|
+
CONTEXT_IN_PROCESS = "context: applies to a schema class executing in-process"
|
|
42
|
+
|
|
43
|
+
# the whole rule, said wherever a retry option is refused
|
|
44
|
+
RETRY_RULE = "retries: is how many attempts follow the first; the other retry options sit beside it"
|
|
45
|
+
private_constant :CONTEXT_IN_PROCESS, :RETRY_RULE
|
|
46
|
+
|
|
47
|
+
def initialize(source, auth: nil, headers: {}, transport: nil, cache: nil, ttl: nil,
|
|
48
|
+
open_timeout: nil, read_timeout: nil, context: nil,
|
|
49
|
+
retries: false, backoff: nil, base_delay: nil, max_delay: nil, jitter: nil, retry_on: nil,
|
|
50
|
+
retry_if: nil, retry_codes: nil, retry_mutations: nil, sleeper: nil, &middleware)
|
|
41
51
|
check_source!(source)
|
|
42
52
|
|
|
53
|
+
# Retry's options, spelled the same and passed straight through; nil
|
|
54
|
+
# is "not given", so their defaults stay in Retry alone
|
|
55
|
+
retry_options = { backoff:, base_delay:, max_delay:, jitter:, retry_on:, retry_if:,
|
|
56
|
+
retry_codes:, retry_mutations:, sleeper: }.compact
|
|
57
|
+
|
|
43
58
|
if source.is_a?(String) && source.match?(URL)
|
|
44
|
-
raise ArgumentError,
|
|
59
|
+
raise ArgumentError, CONTEXT_IN_PROCESS if context
|
|
45
60
|
|
|
46
61
|
built = build_transport(source, auth:, headers:, kind: transport, open_timeout:, read_timeout:, &middleware)
|
|
47
|
-
@transport = wrap_retries(built, retries)
|
|
62
|
+
@transport = wrap_retries(built, retries, retry_options)
|
|
48
63
|
else
|
|
49
|
-
if auth || middleware || retries || open_timeout || read_timeout
|
|
64
|
+
if auth || middleware || retries || open_timeout || read_timeout || !retry_options.empty?
|
|
50
65
|
raise ArgumentError, "auth:/retries:/timeouts/middleware apply to a url — got a schema source"
|
|
51
66
|
end
|
|
52
67
|
if transport.is_a?(Symbol)
|
|
@@ -65,7 +80,7 @@ class GraphWeaver::Client
|
|
|
65
80
|
if context && !(source.is_a?(Module) && transport.nil?)
|
|
66
81
|
# nothing would ever read it — a dump has no resolvers, and an
|
|
67
82
|
# explicit transport carries its own
|
|
68
|
-
raise ArgumentError,
|
|
83
|
+
raise ArgumentError, CONTEXT_IN_PROCESS
|
|
69
84
|
end
|
|
70
85
|
|
|
71
86
|
# InProcess adds context:, logging and branded errors to the bare
|
|
@@ -76,6 +91,7 @@ class GraphWeaver::Client
|
|
|
76
91
|
|
|
77
92
|
@cache = cache
|
|
78
93
|
@ttl = ttl
|
|
94
|
+
@schema_lock = Mutex.new
|
|
79
95
|
end
|
|
80
96
|
|
|
81
97
|
# The transport queries run through: a url-built transport, an
|
|
@@ -85,15 +101,21 @@ class GraphWeaver::Client
|
|
|
85
101
|
attr_reader :transport
|
|
86
102
|
|
|
87
103
|
# transport, when this client must be able to execute
|
|
88
|
-
def transport!
|
|
104
|
+
private def transport!
|
|
89
105
|
transport or raise GraphWeaver::Error,
|
|
90
106
|
"this client has no transport (built from a schema dump) — pass a url or transport:"
|
|
91
107
|
end
|
|
92
108
|
|
|
93
109
|
# The schema, introspecting through the transport on first use (cached
|
|
94
110
|
# per the client's cache:/ttl:) unless one was given up front.
|
|
111
|
+
#
|
|
112
|
+
# Locked because a cold Puma process serves its first requests
|
|
113
|
+
# concurrently: a bare ||= there is one full introspection round trip per
|
|
114
|
+
# in-flight thread, each of them also writing the cache file.
|
|
95
115
|
def schema
|
|
96
|
-
@
|
|
116
|
+
@schema_lock.synchronize do
|
|
117
|
+
@schema ||= GraphWeaver::SchemaLoader.introspect(transport!, cache: @cache, ttl: @ttl)
|
|
118
|
+
end
|
|
97
119
|
end
|
|
98
120
|
|
|
99
121
|
# The client contract, same as every transport: a query and its
|
|
@@ -146,6 +168,11 @@ class GraphWeaver::Client
|
|
|
146
168
|
def build_transport(url, auth:, headers:, kind:, open_timeout: nil, read_timeout: nil, &middleware)
|
|
147
169
|
headers = headers.dup
|
|
148
170
|
if auth
|
|
171
|
+
unless auth.is_a?(String)
|
|
172
|
+
raise ArgumentError, "auth: takes a token string, got #{auth.class} — other headers go in " \
|
|
173
|
+
"headers:, and a token that rotates goes in the Faraday middleware block"
|
|
174
|
+
end
|
|
175
|
+
|
|
149
176
|
headers["Authorization"] ||= auth.include?(" ") ? auth : "Bearer #{auth}"
|
|
150
177
|
end
|
|
151
178
|
|
|
@@ -159,7 +186,7 @@ class GraphWeaver::Client
|
|
|
159
186
|
GraphWeaver::Transport::HTTP.new(url, headers:, **timeouts)
|
|
160
187
|
end
|
|
161
188
|
|
|
162
|
-
GraphWeaver.log(:info) { "transport: #{transport.class} -> #{url}" }
|
|
189
|
+
GraphWeaver::Internal::Log.log(:info) { "transport: #{transport.class} -> #{url}" }
|
|
163
190
|
transport
|
|
164
191
|
end
|
|
165
192
|
|
|
@@ -194,13 +221,23 @@ class GraphWeaver::Client
|
|
|
194
221
|
GraphWeaver::Transport::Faraday.new(url, headers:, **timeouts, &middleware)
|
|
195
222
|
end
|
|
196
223
|
|
|
197
|
-
# retries: is off by default — true for Retry
|
|
198
|
-
#
|
|
199
|
-
|
|
224
|
+
# retries: is off by default — a count, or true for Retry's default
|
|
225
|
+
# count. Without it nothing wraps the transport, so a retry option on
|
|
226
|
+
# its own would quietly do nothing.
|
|
227
|
+
def wrap_retries(transport, retries, options)
|
|
200
228
|
case retries
|
|
201
|
-
when
|
|
202
|
-
when
|
|
203
|
-
|
|
229
|
+
when Integer then GraphWeaver::Retry.new(transport, retries:, **options)
|
|
230
|
+
when true then GraphWeaver::Retry.new(transport, **options)
|
|
231
|
+
when false, nil
|
|
232
|
+
raise ArgumentError, "#{options.keys.first}: needs retries: — #{RETRY_RULE}" if options.any?
|
|
233
|
+
|
|
234
|
+
transport
|
|
235
|
+
when Hash
|
|
236
|
+
# it used to take a Hash of Retry options, which read as a key nested in itself
|
|
237
|
+
flat = retries.map { |key, value| "#{key}: #{value.inspect}" }.join(", ")
|
|
238
|
+
raise ArgumentError, "retries: no longer takes a Hash — pass GraphWeaver.new(url, #{flat})"
|
|
239
|
+
else
|
|
240
|
+
raise ArgumentError, "#{RETRY_RULE} — got #{retries.inspect}"
|
|
204
241
|
end
|
|
205
242
|
end
|
|
206
243
|
end
|
|
@@ -51,14 +51,19 @@ class GraphWeaver::Codegen
|
|
|
51
51
|
# since a module and the type it queries can share a name (module Query
|
|
52
52
|
# on type Query would otherwise stutter).
|
|
53
53
|
def qualify(node, message)
|
|
54
|
-
return message if @
|
|
54
|
+
return message if @name.nil? || @name == node.graphql_type
|
|
55
55
|
|
|
56
|
-
"#{@
|
|
56
|
+
"#{@name}: #{message}"
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# An alias emits a plain instance method, so it is held to the same bar
|
|
60
|
+
# as a wire field's prop: a name the struct already answers to would be
|
|
61
|
+
# silently overridden, and `hash` or `inspect` breaks the object rather
|
|
62
|
+
# than the file.
|
|
59
63
|
def check_alias_name!(node, name)
|
|
60
64
|
taken = node.fields.any? { |f| f.prop == name } ||
|
|
61
|
-
|
|
65
|
+
STRUCT_METHODS.include?(name) || ALIAS_RESERVED.include?(name) ||
|
|
66
|
+
RUBY_KEYWORDS.include?(name)
|
|
62
67
|
return unless taken
|
|
63
68
|
|
|
64
69
|
raise GraphWeaver::Error,
|
|
@@ -70,9 +75,10 @@ class GraphWeaver::Codegen
|
|
|
70
75
|
GraphWeaver::Codegen.type_registry[graphql_name]&.dig(:aliases) || {}
|
|
71
76
|
end
|
|
72
77
|
|
|
73
|
-
# methods
|
|
74
|
-
#
|
|
75
|
-
|
|
78
|
+
# The CLASS methods a generated struct defines; STRUCT_METHODS covers the
|
|
79
|
+
# instance side, and both are checked with RUBY_KEYWORDS alongside (all
|
|
80
|
+
# three are defined by the class this mixes into).
|
|
81
|
+
ALIAS_RESERVED = %w[from_h].to_set.freeze
|
|
76
82
|
# list selectors — pick one element out of a list-typed hop, always nilable
|
|
77
83
|
# (the list may be empty). Everything else is a field prop.
|
|
78
84
|
LIST_SELECTORS = %w[first last].freeze
|
|
@@ -80,6 +86,7 @@ class GraphWeaver::Codegen
|
|
|
80
86
|
# ever satisfy it, so it's a typo (or a wire-cased name), not a path that
|
|
81
87
|
# doesn't fit this query. optional: skips the latter, never this.
|
|
82
88
|
UnknownSegment = Class.new(GraphWeaver::Error)
|
|
89
|
+
private_constant :ALIAS_RESERVED, :LIST_SELECTORS, :UnknownSegment
|
|
83
90
|
|
|
84
91
|
# Walk a dotted path through this struct's selected shape, building the
|
|
85
92
|
# delegator expression (`meta&.tag`, `_entities.first&.name`) and its return
|
|
@@ -131,7 +138,7 @@ class GraphWeaver::Codegen
|
|
|
131
138
|
unless field
|
|
132
139
|
check_segment_exists!(node, name, obj, seg)
|
|
133
140
|
props = obj.fields.map(&:prop)
|
|
134
|
-
suggestion = GraphWeaver.did_you_mean(props, seg)
|
|
141
|
+
suggestion = GraphWeaver::Internal::Util.did_you_mean(props, seg)
|
|
135
142
|
hint = suggestion ? " — did you mean '#{suggestion}'?" : " (have: #{props.join(", ")})"
|
|
136
143
|
raise GraphWeaver::Error,
|
|
137
144
|
"alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a selected field#{hint}"
|
|
@@ -163,7 +170,7 @@ class GraphWeaver::Codegen
|
|
|
163
170
|
hint = if prop != seg && known.include?(prop)
|
|
164
171
|
# paths are the Ruby prop chain, not the GraphQL one — the classic miss
|
|
165
172
|
" — GraphQL fields generate snake_case props; use '#{prop}'"
|
|
166
|
-
elsif (suggestion = GraphWeaver.did_you_mean(known, prop))
|
|
173
|
+
elsif (suggestion = GraphWeaver::Internal::Util.did_you_mean(known, prop))
|
|
167
174
|
" — did you mean '#{suggestion}'?"
|
|
168
175
|
else
|
|
169
176
|
" (has: #{known.sort.join(", ")})"
|