graph_weaver 0.5.1 → 0.6.1
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 +432 -0
- data/Gemfile.lock +19 -19
- data/README.md +75 -54
- data/docs/cassettes.md +6 -1
- data/docs/editors.md +3 -1
- data/docs/errors.md +73 -16
- data/docs/federation.md +201 -151
- data/docs/generated_modules.md +222 -165
- data/docs/getting_started.md +106 -82
- data/docs/logging.md +34 -4
- data/docs/real_world.md +4 -4
- data/docs/scalars.md +206 -46
- data/docs/testing.md +191 -151
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +210 -11
- data/lib/generators/graph_weaver/install_generator.rb +16 -1
- data/lib/graph_weaver/client.rb +46 -13
- data/lib/graph_weaver/codegen/aliases.rb +5 -4
- data/lib/graph_weaver/codegen/emit.rb +96 -39
- data/lib/graph_weaver/codegen/enum_type.rb +3 -0
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +167 -98
- data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
- data/lib/graph_weaver/codegen.rb +279 -84
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +30 -7
- data/lib/graph_weaver/federation.rb +6 -5
- 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 +39 -41
- data/lib/graph_weaver/internal/values.rb +184 -0
- data/lib/graph_weaver/internal.rb +206 -0
- data/lib/graph_weaver/logging.rb +108 -20
- data/lib/graph_weaver/parsing.rb +5 -4
- 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 +50 -11
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +96 -29
- data/lib/graph_weaver/tasks.rb +78 -29
- data/lib/graph_weaver/testing/cassette.rb +49 -65
- data/lib/graph_weaver/testing/coverage.rb +5 -4
- data/lib/graph_weaver/testing/failure.rb +10 -6
- 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 +94 -808
- data/lib/graph_weaver/testing.rb +35 -84
- 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 +203 -119
- metadata +10 -5
- data/lib/graph_weaver/schemas.rb +0 -48
- data/lib/graph_weaver/selection.rb +0 -120
- data/lib/graph_weaver/testing/values.rb +0 -98
data/docs/upgrading.md
CHANGED
|
@@ -1,4 +1,174 @@
|
|
|
1
|
-
# Upgrading
|
|
1
|
+
# Upgrading
|
|
2
|
+
|
|
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 (a value
|
|
167
|
+
object of your own: 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
|
|
2
172
|
|
|
3
173
|
0.5.0 is one large breaking release. Almost all of it is caught mechanically,
|
|
4
174
|
in this order:
|
|
@@ -6,7 +176,7 @@ in this order:
|
|
|
6
176
|
```sh
|
|
7
177
|
# 1. rename the path settings first — generate won't load without them
|
|
8
178
|
# (queries_path -> queries_paths, generated_path -> generated_paths,
|
|
9
|
-
# fragments_path -> fragments_paths; see "
|
|
179
|
+
# fragments_path -> fragments_paths; see "Path settings are lists" below)
|
|
10
180
|
|
|
11
181
|
bundle exec tapioca gem graph_weaver # 2. regenerate the RBI
|
|
12
182
|
rake graph_weaver:generate # 3. the emitted call shape changed
|
|
@@ -23,7 +193,7 @@ Regenerate the RBI and what remains is only your own call sites.
|
|
|
23
193
|
Generated code is `# typed: strict`, so step 4 finds those for you. The rest of
|
|
24
194
|
this page is what a typechecker can't see.
|
|
25
195
|
|
|
26
|
-
|
|
196
|
+
### `execute` means one thing now
|
|
27
197
|
|
|
28
198
|
Every client answers the same call — `execute(query, variables:, operation_name:)`,
|
|
29
199
|
returning the raw response hash. `Client` used to spell something else under
|
|
@@ -55,7 +225,7 @@ PersonQuery.execute(client: some_client, id: "1") # after
|
|
|
55
225
|
|
|
56
226
|
`GraphWeaver.resolve_transport` is gone; nothing needs unwrapping any more.
|
|
57
227
|
|
|
58
|
-
|
|
228
|
+
### Path settings are lists
|
|
59
229
|
|
|
60
230
|
`queries_paths`, `generated_paths`, `fragments_paths` — every entry is read.
|
|
61
231
|
Assigning a String still works, so the change is the name:
|
|
@@ -67,9 +237,10 @@ GraphWeaver.queries_paths = "app/graphql/queries" # after
|
|
|
67
237
|
|
|
68
238
|
`schema_path` stays singular: one run reads one schema.
|
|
69
239
|
|
|
70
|
-
|
|
240
|
+
### One reset
|
|
71
241
|
|
|
72
|
-
`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
|
|
73
244
|
narrow ones moved to where they live:
|
|
74
245
|
|
|
75
246
|
```ruby
|
|
@@ -78,12 +249,39 @@ GraphWeaver::Codegen.reset_scalars! # after (also reset_enums!, clear_scalar
|
|
|
78
249
|
# reset_type_helpers!)
|
|
79
250
|
```
|
|
80
251
|
|
|
81
|
-
|
|
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
|
|
82
281
|
|
|
83
282
|
| before | after |
|
|
84
283
|
|---|---|
|
|
85
284
|
| `Testing.config.auto_fake = true` | `Testing.config.default_mode = :fake` |
|
|
86
|
-
| `register_scalar(…, coerce: :to_s)` | `coerce: true`, or a `cast:`/`serialize:` pair |
|
|
87
285
|
| a mutation's `…Query` module | `…Mutation` |
|
|
88
286
|
| `graphql: :none` (rspec tag) | `graphql: false` |
|
|
89
287
|
|
|
@@ -103,7 +301,7 @@ graphql_in_process(MySchema) # in the example
|
|
|
103
301
|
GraphWeaver::Testing.config.schema = MySchema # or once, for the whole suite
|
|
104
302
|
```
|
|
105
303
|
|
|
106
|
-
|
|
304
|
+
### Registering from Rails
|
|
107
305
|
|
|
108
306
|
A registration naming one of your own constants belongs in a `to_prepare` block
|
|
109
307
|
— the same place the in-process client goes, and for the same reason:
|
|
@@ -117,9 +315,10 @@ end
|
|
|
117
315
|
```
|
|
118
316
|
|
|
119
317
|
Generation depends on `:environment`, which runs `to_prepare` too, so the
|
|
120
|
-
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.
|
|
121
320
|
|
|
122
|
-
|
|
321
|
+
### If you use the federation router
|
|
123
322
|
|
|
124
323
|
Detection only sees *loaded* schema classes, and Rails does not eager load for
|
|
125
324
|
rake or in the default test environment. Both are one line:
|
|
@@ -56,8 +56,11 @@ module GraphWeaver
|
|
|
56
56
|
create_file "config/initializers/graph_weaver.rb", initializer
|
|
57
57
|
end
|
|
58
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
|
|
59
61
|
def create_layout
|
|
60
62
|
create_file File.join(GraphWeaver.queries_paths.first, ".keep"), ""
|
|
63
|
+
create_file File.join(GraphWeaver.fragments_paths.first, ".keep"), ""
|
|
61
64
|
create_file File.join(GraphWeaver.generated_paths.first, ".keep"), ""
|
|
62
65
|
end
|
|
63
66
|
|
|
@@ -85,10 +88,18 @@ module GraphWeaver
|
|
|
85
88
|
rescue StandardError => e
|
|
86
89
|
# the files above are the valuable part — don't lose them to a bad
|
|
87
90
|
# token or an unreachable host
|
|
91
|
+
@schema_failed = true
|
|
88
92
|
say_status :failed, "#{e.message} — retry with `#{refresh_command}`", :red
|
|
89
93
|
end
|
|
90
94
|
|
|
91
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
|
+
|
|
92
103
|
say <<~TEXT
|
|
93
104
|
|
|
94
105
|
Write a query in #{GraphWeaver.queries_paths.first}, then:
|
|
@@ -175,8 +186,12 @@ module GraphWeaver
|
|
|
175
186
|
|
|
176
187
|
def auth_var = options[:auth] || GraphWeaver::SchemaLoader::DEFAULT_AUTH_ENV
|
|
177
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.
|
|
178
193
|
def refresh_command
|
|
179
|
-
|
|
194
|
+
"rails g graph_weaver:install #{source}#{" --auth #{options[:auth]}" if options[:auth]}"
|
|
180
195
|
end
|
|
181
196
|
|
|
182
197
|
def initializer
|
data/lib/graph_weaver/client.rb
CHANGED
|
@@ -40,17 +40,28 @@ class GraphWeaver::Client
|
|
|
40
40
|
# nothing to hand a context to — so the two can't word it differently
|
|
41
41
|
CONTEXT_IN_PROCESS = "context: applies to a schema class executing in-process"
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
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)
|
|
45
51
|
check_source!(source)
|
|
46
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
|
+
|
|
47
58
|
if source.is_a?(String) && source.match?(URL)
|
|
48
59
|
raise ArgumentError, CONTEXT_IN_PROCESS if context
|
|
49
60
|
|
|
50
61
|
built = build_transport(source, auth:, headers:, kind: transport, open_timeout:, read_timeout:, &middleware)
|
|
51
|
-
@transport = wrap_retries(built, retries)
|
|
62
|
+
@transport = wrap_retries(built, retries, retry_options)
|
|
52
63
|
else
|
|
53
|
-
if auth || middleware || retries || open_timeout || read_timeout
|
|
64
|
+
if auth || middleware || retries || open_timeout || read_timeout || !retry_options.empty?
|
|
54
65
|
raise ArgumentError, "auth:/retries:/timeouts/middleware apply to a url — got a schema source"
|
|
55
66
|
end
|
|
56
67
|
if transport.is_a?(Symbol)
|
|
@@ -80,6 +91,7 @@ class GraphWeaver::Client
|
|
|
80
91
|
|
|
81
92
|
@cache = cache
|
|
82
93
|
@ttl = ttl
|
|
94
|
+
@schema_lock = Mutex.new
|
|
83
95
|
end
|
|
84
96
|
|
|
85
97
|
# The transport queries run through: a url-built transport, an
|
|
@@ -89,15 +101,21 @@ class GraphWeaver::Client
|
|
|
89
101
|
attr_reader :transport
|
|
90
102
|
|
|
91
103
|
# transport, when this client must be able to execute
|
|
92
|
-
def transport!
|
|
104
|
+
private def transport!
|
|
93
105
|
transport or raise GraphWeaver::Error,
|
|
94
106
|
"this client has no transport (built from a schema dump) — pass a url or transport:"
|
|
95
107
|
end
|
|
96
108
|
|
|
97
109
|
# The schema, introspecting through the transport on first use (cached
|
|
98
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.
|
|
99
115
|
def schema
|
|
100
|
-
@
|
|
116
|
+
@schema_lock.synchronize do
|
|
117
|
+
@schema ||= GraphWeaver::SchemaLoader.introspect(transport!, cache: @cache, ttl: @ttl)
|
|
118
|
+
end
|
|
101
119
|
end
|
|
102
120
|
|
|
103
121
|
# The client contract, same as every transport: a query and its
|
|
@@ -150,6 +168,11 @@ class GraphWeaver::Client
|
|
|
150
168
|
def build_transport(url, auth:, headers:, kind:, open_timeout: nil, read_timeout: nil, &middleware)
|
|
151
169
|
headers = headers.dup
|
|
152
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
|
+
|
|
153
176
|
headers["Authorization"] ||= auth.include?(" ") ? auth : "Bearer #{auth}"
|
|
154
177
|
end
|
|
155
178
|
|
|
@@ -163,7 +186,7 @@ class GraphWeaver::Client
|
|
|
163
186
|
GraphWeaver::Transport::HTTP.new(url, headers:, **timeouts)
|
|
164
187
|
end
|
|
165
188
|
|
|
166
|
-
GraphWeaver.log(:info) { "transport: #{transport.class} -> #{url}" }
|
|
189
|
+
GraphWeaver::Internal::Log.log(:info) { "transport: #{transport.class} -> #{url}" }
|
|
167
190
|
transport
|
|
168
191
|
end
|
|
169
192
|
|
|
@@ -198,13 +221,23 @@ class GraphWeaver::Client
|
|
|
198
221
|
GraphWeaver::Transport::Faraday.new(url, headers:, **timeouts, &middleware)
|
|
199
222
|
end
|
|
200
223
|
|
|
201
|
-
# retries: is off by default — true for Retry
|
|
202
|
-
#
|
|
203
|
-
|
|
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)
|
|
204
228
|
case retries
|
|
205
|
-
when
|
|
206
|
-
when
|
|
207
|
-
|
|
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}"
|
|
208
241
|
end
|
|
209
242
|
end
|
|
210
243
|
end
|
|
@@ -51,9 +51,9 @@ 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
59
|
# An alias emits a plain instance method, so it is held to the same bar
|
|
@@ -86,6 +86,7 @@ class GraphWeaver::Codegen
|
|
|
86
86
|
# ever satisfy it, so it's a typo (or a wire-cased name), not a path that
|
|
87
87
|
# doesn't fit this query. optional: skips the latter, never this.
|
|
88
88
|
UnknownSegment = Class.new(GraphWeaver::Error)
|
|
89
|
+
private_constant :ALIAS_RESERVED, :LIST_SELECTORS, :UnknownSegment
|
|
89
90
|
|
|
90
91
|
# Walk a dotted path through this struct's selected shape, building the
|
|
91
92
|
# delegator expression (`meta&.tag`, `_entities.first&.name`) and its return
|
|
@@ -137,7 +138,7 @@ class GraphWeaver::Codegen
|
|
|
137
138
|
unless field
|
|
138
139
|
check_segment_exists!(node, name, obj, seg)
|
|
139
140
|
props = obj.fields.map(&:prop)
|
|
140
|
-
suggestion = GraphWeaver.did_you_mean(props, seg)
|
|
141
|
+
suggestion = GraphWeaver::Internal::Util.did_you_mean(props, seg)
|
|
141
142
|
hint = suggestion ? " — did you mean '#{suggestion}'?" : " (have: #{props.join(", ")})"
|
|
142
143
|
raise GraphWeaver::Error,
|
|
143
144
|
"alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a selected field#{hint}"
|
|
@@ -169,7 +170,7 @@ class GraphWeaver::Codegen
|
|
|
169
170
|
hint = if prop != seg && known.include?(prop)
|
|
170
171
|
# paths are the Ruby prop chain, not the GraphQL one — the classic miss
|
|
171
172
|
" — GraphQL fields generate snake_case props; use '#{prop}'"
|
|
172
|
-
elsif (suggestion = GraphWeaver.did_you_mean(known, prop))
|
|
173
|
+
elsif (suggestion = GraphWeaver::Internal::Util.did_you_mean(known, prop))
|
|
173
174
|
" — did you mean '#{suggestion}'?"
|
|
174
175
|
else
|
|
175
176
|
" (has: #{known.sort.join(", ")})"
|