graph_weaver 0.7.4 → 0.7.6
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/Gemfile.lock +2 -2
- data/README.md +1 -0
- data/docs/errors.md +12 -5
- data/docs/generated_modules.md +134 -17
- data/docs/getting_started.md +182 -20
- data/docs/logging.md +79 -35
- data/docs/migrating.md +126 -0
- data/docs/scalars.md +50 -6
- data/docs/testing.md +78 -14
- data/docs/upgrading.md +44 -2
- data/examples/README.md +4 -2
- data/examples/github/generate.rb +22 -8
- data/examples/github/generated/star_mutation.rb +2 -2
- data/examples/github/generated/stargazers_query.rb +2 -2
- data/examples/github/generated/starred_query.rb +2 -2
- data/examples/github/run.rb +1 -0
- data/examples/github/setup.rb +16 -8
- data/graph_weaver.gemspec +15 -6
- data/lib/generators/graph_weaver/install_generator.rb +49 -2
- data/lib/graph_weaver/client.rb +0 -23
- data/lib/graph_weaver/codegen/aliases.rb +36 -3
- data/lib/graph_weaver/codegen/emit.rb +20 -15
- data/lib/graph_weaver/codegen/enum_type.rb +52 -11
- data/lib/graph_weaver/codegen/nodes.rb +75 -32
- data/lib/graph_weaver/codegen.rb +260 -100
- data/lib/graph_weaver/coerce.rb +1 -1
- data/lib/graph_weaver/federation.rb +1 -6
- data/lib/graph_weaver/graph.rb +55 -5
- data/lib/graph_weaver/hints.rb +20 -5
- data/lib/graph_weaver/in_process.rb +2 -4
- data/lib/graph_weaver/input_struct.rb +50 -10
- data/lib/graph_weaver/internal/overrides.rb +126 -14
- data/lib/graph_weaver/internal/subgraphs.rb +1 -10
- data/lib/graph_weaver/internal/test_clients.rb +29 -7
- data/lib/graph_weaver/internal/unused.rb +62 -18
- data/lib/graph_weaver/internal/values.rb +24 -7
- data/lib/graph_weaver/internal.rb +23 -6
- data/lib/graph_weaver/log_subscriber.rb +27 -17
- data/lib/graph_weaver/logging.rb +115 -82
- data/lib/graph_weaver/parsing.rb +32 -3
- data/lib/graph_weaver/query_module.rb +67 -12
- data/lib/graph_weaver/railtie.rb +7 -2
- data/lib/graph_weaver/rspec.rb +41 -18
- data/lib/graph_weaver/schema_diff.rb +24 -5
- data/lib/graph_weaver/schema_loader.rb +29 -17
- data/lib/graph_weaver/tasks.rb +98 -16
- data/lib/graph_weaver/testing/fake_client.rb +28 -31
- data/lib/graph_weaver/testing/router.rb +26 -25
- data/lib/graph_weaver/testing.rb +27 -8
- data/lib/graph_weaver/transport.rb +1 -1
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +72 -42
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3a68eaeb8cd0f3139cc9e50b88d98a50e04acd0c2c94c7825233f04405d30c42
|
|
4
|
+
data.tar.gz: '08df5051075500cb81c710cd65705f387dec16bc29a626e2b8069bbf5c61b3a1'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ee7b2971697409f7180e31d40569fd44108404c0f1d811b259bec6e000f7cf82142e7640de164510d1dbd253916392c6cc866619ee2d37e35e610b1ed0be9741
|
|
7
|
+
data.tar.gz: 9d219008a1db9bc353ef3b033b9c39f64657e58d1d2548fb074fc1a60973052e4abd0c1950d93622ad1cfc0414e7ca1219d9c770e32e445cedba287636f58a73
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
graph_weaver (0.7.
|
|
4
|
+
graph_weaver (0.7.6)
|
|
5
5
|
graphql (>= 2.6.7)
|
|
6
6
|
sorbet-runtime
|
|
7
7
|
|
|
@@ -299,7 +299,7 @@ CHECKSUMS
|
|
|
299
299
|
google-protobuf (4.35.1-arm64-darwin) sha256=d9c957df04fa89c749fa9a72a7b383eb4296efc9b2303dc6fd6fbe39c698ad6b
|
|
300
300
|
google-protobuf (4.35.1-x86_64-darwin) sha256=66b62b4df00931018a692806df66393efa960d6d2b7da69735187249f950d3ee
|
|
301
301
|
google-protobuf (4.35.1-x86_64-linux-gnu) sha256=c786439087512a3fbd199e9897d265b855f951d4027e218ea55e858d45969edd
|
|
302
|
-
graph_weaver (0.7.
|
|
302
|
+
graph_weaver (0.7.6)
|
|
303
303
|
graphql (2.6.10) sha256=9b7c8633767f516ff9d48a8d6305b2a00a2101c82aa871b92e69086944f9f83e
|
|
304
304
|
hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
|
|
305
305
|
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
|
data/README.md
CHANGED
|
@@ -146,6 +146,7 @@ when you've skipped it.
|
|
|
146
146
|
- **[Against a real API](docs/real_world.md)** — introspecting a live endpoint, GitHub end to end
|
|
147
147
|
- **[Logging](docs/logging.md)** — point `GraphWeaver.logger` at any Logger
|
|
148
148
|
- **[Upgrading](docs/upgrading.md)** — regenerate on every bump, and what each release moved
|
|
149
|
+
- **[Migrating](docs/migrating.md)** — coming off graphql-client or a hand-rolled client, commit by commit
|
|
149
150
|
- **[Alternatives](docs/alternatives.md)** — graphql-client, graphlient, artemis, hand-rolled: what each is good at, and where this one loses
|
|
150
151
|
|
|
151
152
|
----
|
data/docs/errors.md
CHANGED
|
@@ -106,6 +106,7 @@ subclass says where it failed:
|
|
|
106
106
|
| `InputError` | the variables wouldn't build into the generated input structs — unknown/typo'd key, missing required field, out-of-range enum, wrong-typed field, wrong number of @oneOf fields — `#kind`, `#path`, `#coordinate`, `#value`, `#details`, `#field`, `#struct` |
|
|
107
107
|
| `QueryValidationError` | build time: the query didn't validate against the schema |
|
|
108
108
|
| `Codegen::Aliases::UnknownSegment` | build time: an [`alias:`](generated_modules.md#flat-accessors-with-alias) path names a field no type here has — a typo, so `optional: true` won't skip it |
|
|
109
|
+
| `Codegen::Aliases::HoistedSegment` | build time: an [`alias:`](generated_modules.md#flat-accessors-with-alias) path reads *into* a struct [hoisted](generated_modules.md#a-shared-fragment-is-one-type) from a shared fragment — `optional: true` skips it, but only at the cost of the accessor for every query that spreads the fragment |
|
|
109
110
|
| `ConfigurationError` | setup judged against your schema — which Ruby schema serves which subgraph (`Testing::Router`, `federation:diff`) |
|
|
110
111
|
| `Testing::Unplannable` | the local test router won't plan this operation — `#category`, `#detail` |
|
|
111
112
|
| `Testing::MissingRecording` | a [cassette](cassettes.md) holds no entry for this request — the message prints the variables, and the ones it did record |
|
|
@@ -172,7 +173,10 @@ A nested filter reports the innermost input type, so the error points at the inp
|
|
|
172
173
|
that actually held the bad field; passing something that is neither — a bare
|
|
173
174
|
`String` where the input goes — reports the same way. A call site that *spells* the
|
|
174
175
|
wrong type is caught earlier and better, by `srb tc`
|
|
175
|
-
([why](generated_modules.md#variables-become-typed-kwargs)).
|
|
176
|
+
([why](generated_modules.md#variables-become-typed-kwargs)). And once a boundary
|
|
177
|
+
of your own between that call site and `execute` carries a sig, sorbet-runtime
|
|
178
|
+
checks the value there first: the caller sees a plain `TypeError`, and none of
|
|
179
|
+
the structured, translatable detail below ever reaches it.
|
|
176
180
|
|
|
177
181
|
The one-shot `GraphWeaver.run` / `run!` mirror `execute` / `execute!`.
|
|
178
182
|
|
|
@@ -216,9 +220,12 @@ a form keyed on `e.field` finds the same slot for a refusal raised before the
|
|
|
216
220
|
request left and for one the server sent back.
|
|
217
221
|
|
|
218
222
|
The **prop** (`issued_on`) is what you type in Ruby — `.new`, `.coerce`, the kwargs
|
|
219
|
-
of `execute` — and it is `#message`, the developer's line, that names it
|
|
220
|
-
|
|
221
|
-
the
|
|
223
|
+
of `execute` — and it is `#message`, the developer's line, that names it, with the
|
|
224
|
+
schema's spelling beside it where the two differ (`issued_on (issuedOn): expected
|
|
225
|
+
a String`): the prop is what you fix, and the wire name is what you grep the
|
|
226
|
+
`.graphql` for. In a Rails form the field names are the props, so underscore on
|
|
227
|
+
the way in — and give the nil case a home, because **`#field` is `nil` whenever
|
|
228
|
+
nothing named a slot**:
|
|
222
229
|
|
|
223
230
|
```ruby
|
|
224
231
|
form.errors.add(e.field&.underscore || :base, render_input_error(e))
|
|
@@ -464,7 +471,7 @@ than leaving you sorbet's words:
|
|
|
464
471
|
| what came back | what the message adds |
|
|
465
472
|
|---|---|
|
|
466
473
|
| an `ID` the server sent unquoted | GraphQL serializes `ID` as a JSON string, so this is the server out of spec — plus how to take it anyway (`register_scalar("ID", "T.untyped")`) |
|
|
467
|
-
| an enum value the generated enum doesn't hold | the values it does hold, and that drift is the likely cause: regenerate, or `register_enum(fallback:)` to absorb them |
|
|
474
|
+
| an enum value the generated enum doesn't hold | the values it does hold, and that drift is the likely cause: regenerate, or `register_enum("Species", fallback: true)` to absorb them into `Other` |
|
|
468
475
|
| a field the server nulled **with a reason** | the server's own explanation, rather than only sorbet's nil complaint |
|
|
469
476
|
|
|
470
477
|
Simulate one in tests with
|
data/docs/generated_modules.md
CHANGED
|
@@ -61,6 +61,18 @@ name your operations**: an anonymous document is named after the module in the
|
|
|
61
61
|
emitted `QUERY` *and* in `OPERATION_NAME` — both, since a server rejects an
|
|
62
62
|
`operationName` its document doesn't declare.
|
|
63
63
|
|
|
64
|
+
Code that takes *any* generated module — a persisted-query manifest, a
|
|
65
|
+
transport of your own — types it as `GraphWeaver::QueryModule` and reads the
|
|
66
|
+
same two through `query_string` and `operation_name`, which carry sigs. That is
|
|
67
|
+
the spelling rubocop-sorbet allows: `mod.const_get(:QUERY)` on a `Module` is
|
|
68
|
+
`Sorbet/ConstantsFromStrings`, and the `T.unsafe` that gets around it is
|
|
69
|
+
`Sorbet/ForbidTUnsafe`.
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
sig { params(mod: GraphWeaver::QueryModule).returns(String) }
|
|
73
|
+
def manifest_line(mod) = "#{mod.operation_name}: #{mod.query_string.bytesize} bytes"
|
|
74
|
+
```
|
|
75
|
+
|
|
64
76
|
A `Result` is an **ordinary Ruby object**: value `==` (with `eql?` and `hash`,
|
|
65
77
|
so a result works as a hash key), `deconstruct_keys` for pattern matching,
|
|
66
78
|
`#to_h`, and `#to_json`/`#as_json`. All of them go the whole way down a nested
|
|
@@ -167,10 +179,11 @@ query: `pet: pets { name }` generates `Pet` (and a `.pet` accessor).
|
|
|
167
179
|
|
|
168
180
|
**Union and interface members** are the one name that doesn't come from a key:
|
|
169
181
|
they take the type condition that produces them (`... on Book` → `Book`) inside
|
|
170
|
-
the container named for the field, plus the catch-all `Other`; a
|
|
171
|
-
out of a shared fragment is named for
|
|
172
|
-
one collapsed union type take the first
|
|
173
|
-
position-determined, all of it
|
|
182
|
+
the container named for the field, plus the catch-all `Other`; a type
|
|
183
|
+
[hoisted](#a-shared-fragment-is-one-type) out of a shared fragment is named for
|
|
184
|
+
the fragment; and several fields sharing one collapsed union type take the first
|
|
185
|
+
of their keys alphabetically. Still position-determined, all of it — a fragment
|
|
186
|
+
name is a source fact like a result key.
|
|
174
187
|
|
|
175
188
|
Two collisions are handled rather than left to surprise you. A name that would
|
|
176
189
|
shadow the struct it nests in (`pet { pet { ... } }`) takes a numeric suffix
|
|
@@ -321,6 +334,12 @@ Both spellings then cast to `Status::LegacyMode`, only the target gets a
|
|
|
321
334
|
constant, and a variable sends the target — see
|
|
322
335
|
[scalars.md](scalars.md#two-spellings-one-value).
|
|
323
336
|
|
|
337
|
+
A value the *server* adds after you generate is drift, and casting it raises by
|
|
338
|
+
default. `register_enum("Species", fallback: true)` gives the generated enum one
|
|
339
|
+
extra member, `Other`, that every undeclared wire value casts to — the same
|
|
340
|
+
catch-all an abstract type's dispatch always carries. A variable can't send it;
|
|
341
|
+
see [scalars.md](scalars.md#values-the-server-hasnt-told-you-about-yet).
|
|
342
|
+
|
|
324
343
|
`register_enum` replaces the generated `T::Enum` with your own app enum — see
|
|
325
344
|
[scalars.md](scalars.md#enums-map-onto-your-own-tenum). Dynamic `parse` emits the
|
|
326
345
|
enums into the query module itself; there's no cross-query set to share against,
|
|
@@ -369,6 +388,46 @@ schema's spelling in both directions, so the query, the request, the response,
|
|
|
369
388
|
and `#as_json`/`#to_json` are untouched and `render json: result` never leaks a
|
|
370
389
|
trailing underscore.
|
|
371
390
|
|
|
391
|
+
### A shared fragment is one type
|
|
392
|
+
|
|
393
|
+
**A whole field selected as exactly one named shared fragment is one type in
|
|
394
|
+
`GraphQLTypes`, named for the fragment, which each query aliases.** Object or
|
|
395
|
+
abstract, same sentence:
|
|
396
|
+
|
|
397
|
+
```graphql
|
|
398
|
+
# app/graphql/fragments/pet_fields.graphql
|
|
399
|
+
fragment PetFields on Pet { name species birthday }
|
|
400
|
+
|
|
401
|
+
# two queries, one Ruby type
|
|
402
|
+
query Roster { pets { ...PetFields } }
|
|
403
|
+
query Shelter { shelter { pets { ...PetFields } } }
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
```ruby
|
|
407
|
+
GraphQLTypes::PetFields # the struct, generated once
|
|
408
|
+
RosterQuery::PetFields # the alias each query opens with
|
|
409
|
+
sig { params(pet: GraphQLTypes::PetFields).returns(String) } # nameable by your app
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
That is what makes the fragment the unit of reuse on *both* sides of the wire: a
|
|
413
|
+
presenter, a serializer or a policy can name its argument's type, and a union
|
|
414
|
+
spread this way gives one exhaustive `case … T.absurd` everywhere instead of a
|
|
415
|
+
fresh dispatch module per query.
|
|
416
|
+
|
|
417
|
+
It is opt-in by how you write the query, and only the exact shape hoists —
|
|
418
|
+
`{ ...PetFields id }`, `{ ...PetFields ...Vaccinations }`, a spread carrying
|
|
419
|
+
`@skip`/`@include`, a fragment the query file defines itself, and a fragment
|
|
420
|
+
written on some other type than the field's all stay a position-named struct
|
|
421
|
+
where they are. A spread *inside* a hoisted fragment is inlined into it rather
|
|
422
|
+
than hoisted again. Like shared inputs, it's a `generate!`-directory concern;
|
|
423
|
+
dynamic `parse` inlines everything.
|
|
424
|
+
|
|
425
|
+
Registrations follow the type, not the position, so `extend_type("Pet", …)`
|
|
426
|
+
mixins and `alias:` accessors land on the hoisted struct exactly as they would
|
|
427
|
+
on a local one. An `alias:` path on the *parent* may end on the hoisted struct
|
|
428
|
+
(`alias: { first_pet: "pets.first" }`) but not read through it — the struct
|
|
429
|
+
belongs to another module — and says so at generation.
|
|
430
|
+
|
|
372
431
|
### Abstract types
|
|
373
432
|
|
|
374
433
|
An abstract field emits **one struct per type condition the selection names**,
|
|
@@ -402,11 +461,12 @@ off `__typename` when the selection carries one unaliased and unguarded, and off
|
|
|
402
461
|
narrowed fragment, or one whose `__typename` is itself guarded, is refused: a
|
|
403
462
|
match would be indistinguishable from a miss.
|
|
404
463
|
|
|
405
|
-
|
|
406
|
-
(`{ ...FeedItemFields }`)
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
it
|
|
464
|
+
A whole abstract field selected as one named *shared* fragment
|
|
465
|
+
(`{ ...FeedItemFields }`) is [hoisted](#a-shared-fragment-is-one-type) into
|
|
466
|
+
`GraphQLTypes` like any other, in whichever of the three shapes above the
|
|
467
|
+
fragment has — a dispatch module, one struct, or a nilable narrowed struct. So
|
|
468
|
+
which shape it is never decides whether consuming queries share a type, and
|
|
469
|
+
adding a second `... on` to a shared fragment doesn't move their constant.
|
|
410
470
|
|
|
411
471
|
### Consuming a union — dispatch on the class, not `__typename`
|
|
412
472
|
|
|
@@ -438,8 +498,9 @@ you handle it.** It is exhaustive over the members *this query asked about*, plu
|
|
|
438
498
|
cover: two *differently-selected* occurrences of the same union are distinct type
|
|
439
499
|
families (`Result::Item::Book` is not `Result::FeaturedItem::Book`), so a `case`
|
|
440
500
|
written for one won't span the other. Select the union through a shared fragment
|
|
441
|
-
to hold it as one type across queries
|
|
442
|
-
is
|
|
501
|
+
to hold it as one type across queries
|
|
502
|
+
([above](#a-shared-fragment-is-one-type)); if all you have is the bare tag,
|
|
503
|
+
`__typename` is the common denominator, unchecked.
|
|
443
504
|
|
|
444
505
|
## Type helpers
|
|
445
506
|
|
|
@@ -503,6 +564,22 @@ the one thing you couldn't add by hand: a `const` has no sig to put it in.
|
|
|
503
564
|
`T.unsafe(self).name` also silences it, at the cost of checking nothing. Either
|
|
504
565
|
beats `# typed: false` for a helper you want checked.
|
|
505
566
|
|
|
567
|
+
**An `abstract!` mixin has a precondition: every query must select what it
|
|
568
|
+
declares.** The mixin goes into *every* struct generated from the type, so a
|
|
569
|
+
query selecting a subset has nothing to satisfy the rest, and generation refuses
|
|
570
|
+
— naming the struct, the mixin, the members, and the two ways out: select them
|
|
571
|
+
in that query, or select the type through one
|
|
572
|
+
[shared fragment](#a-shared-fragment-is-one-type), which hoists a single struct
|
|
573
|
+
for every query to share. The second is usually the answer: a shape several
|
|
574
|
+
queries lean on is a fragment.
|
|
575
|
+
|
|
576
|
+
One sig a mixin cannot write is one over a *generated* type — `sig {
|
|
577
|
+
abstract.returns(GraphQLTypes::Species) }` raises `NameError` at generation,
|
|
578
|
+
because registrations load before the enum that generation is about to write.
|
|
579
|
+
The field's sig has to be `T.untyped` there. The struct's own `const` still
|
|
580
|
+
carries the real type, so a call site reading `pet.species` is checked; only the
|
|
581
|
+
mixin's view of it isn't.
|
|
582
|
+
|
|
506
583
|
### Flat accessors with `alias:`
|
|
507
584
|
|
|
508
585
|
The one derivation the generator can type for you is a plain projection — a
|
|
@@ -528,7 +605,8 @@ alias: { label: "name", tag: "meta.tag" }
|
|
|
528
605
|
```
|
|
529
606
|
|
|
530
607
|
The path is the **Ruby** accessor chain, so its segments are snake_case props
|
|
531
|
-
(`name_with_owner.tag`), not wire names. It
|
|
608
|
+
(`name_with_owner.tag`), not wire names. It may end on a
|
|
609
|
+
[hoisted](#a-shared-fragment-is-one-type) struct but not read through one. It's typed from the selection: any
|
|
532
610
|
nullable hop makes the accessor nilable and inserts `&.`; the leaf can be a
|
|
533
611
|
scalar, enum, or nested struct. It's validated against each query at generation —
|
|
534
612
|
an unselected or misspelled segment (`did you mean 'tag'?`), a selector on a
|
|
@@ -597,7 +675,7 @@ to replace it; what the *example* says still wins.
|
|
|
597
675
|
generated module). A generated file says nothing about transport — only a private
|
|
598
676
|
`GRAPH` naming its graph, which is also how `graphql: :fake` fabricates each
|
|
599
677
|
module's own schema with two graphs in play, and the
|
|
600
|
-
`:graph` on every [instrumentation event](logging.md#
|
|
678
|
+
`:graph` on every [instrumentation event](logging.md#instrumentation) the module's
|
|
601
679
|
`execute` produces.
|
|
602
680
|
|
|
603
681
|
## Deserializing a response from another client
|
|
@@ -679,8 +757,8 @@ it goes. The schema dump is step 0: codegen reads it, never a live endpoint, and
|
|
|
679
757
|
generating without one fails pointing at exactly that.
|
|
680
758
|
|
|
681
759
|
**A type shared across query modules lives in `GraphQLTypes` and is aliased in.**
|
|
682
|
-
Input types, schema enums, and
|
|
683
|
-
kind of thing — a type that would otherwise be copied into every query that
|
|
760
|
+
Input types, schema enums, and the types hoisted from shared fragments are all
|
|
761
|
+
one kind of thing — a type that would otherwise be copied into every query that
|
|
684
762
|
touches it — so they live in one module, one file each, and a query module that
|
|
685
763
|
uses any of them opens with `require_relative "types"`. Rename the constant
|
|
686
764
|
(`GraphWeaver.types_module=`, or `generate!(types_module:)`) when one app
|
|
@@ -716,6 +794,43 @@ spot a *schema* change for you — `schema:diff`, `schema:refresh`, `queries:che
|
|
|
716
794
|
— are in [getting started](getting_started.md#5-verify-in-ci); a
|
|
717
795
|
[`schema_stale?`](errors.md) error in production is the late signal.
|
|
718
796
|
|
|
797
|
+
### Make your tooling treat generated/ as generated
|
|
798
|
+
|
|
799
|
+
Three readers, three tellings — and `rails g graph_weaver:install` writes the
|
|
800
|
+
two that live in files:
|
|
801
|
+
|
|
802
|
+
- **You.** Every generated file opens with `# Generated by GraphWeaver
|
|
803
|
+
<version> — do not edit.`
|
|
804
|
+
- **Rubocop.** The output directory goes into `AllCops: Exclude:` — machine-written
|
|
805
|
+
code isn't yours to style, and plain `rubocop` otherwise fires
|
|
806
|
+
`Style/Documentation` on every struct and `Metrics/*` on every `from_h`.
|
|
807
|
+
- **GitHub.** `linguist-generated` collapses these files in a pull-request diff
|
|
808
|
+
(shown as "Load diff") and drops them from the repository's language
|
|
809
|
+
breakdown. It is display-only: they stay versioned, expandable, and `git diff`
|
|
810
|
+
here is unchanged.
|
|
811
|
+
|
|
812
|
+
An app that didn't run the installer adds the two by hand:
|
|
813
|
+
|
|
814
|
+
```yaml
|
|
815
|
+
# .rubocop.yml — inherit_mode keeps rubocop's own Exclude defaults
|
|
816
|
+
AllCops:
|
|
817
|
+
inherit_mode:
|
|
818
|
+
merge:
|
|
819
|
+
- Exclude
|
|
820
|
+
Exclude:
|
|
821
|
+
- "app/graphql/generated/**/*"
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
```text
|
|
825
|
+
# .gitattributes — gitignore-style, so `dir/**` is everything beneath it
|
|
826
|
+
app/graphql/generated/** linguist-generated
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
`git check-attr linguist-generated app/graphql/generated/types.rb` answers `set`
|
|
830
|
+
when the pattern is right. One line per graph in a multi-schema app, and
|
|
831
|
+
[linguist's overrides](https://github.com/github-linguist/linguist/blob/main/docs/overrides.md)
|
|
832
|
+
has the other attributes it reads.
|
|
833
|
+
|
|
719
834
|
### Loading what it wrote
|
|
720
835
|
|
|
721
836
|
In Rails, loading is automatic — the Railtie requires every generated file at
|
|
@@ -735,8 +850,10 @@ you — a script that generates its own modules sets all four:
|
|
|
735
850
|
other [client](#clients) to reach for.
|
|
736
851
|
|
|
737
852
|
Miss (3) and the script gets a `NameError` for its own module; miss (4) and it
|
|
738
|
-
gets `
|
|
739
|
-
|
|
853
|
+
gets `no client configured — set GraphWeaver.client= or pass a client` from a
|
|
854
|
+
module that otherwise looks fine. Set the slot to something that can't execute
|
|
855
|
+
and the module names the contract instead: `PersonQuery: client must respond to
|
|
856
|
+
#execute(query, variables:, operation_name:), got Hash`.
|
|
740
857
|
|
|
741
858
|
Every directory setting is a list — `queries_paths`, `generated_paths`,
|
|
742
859
|
`fragments_paths` — and every entry is read (entries may be globs; the generated
|
data/docs/getting_started.md
CHANGED
|
@@ -6,6 +6,8 @@ Follow it once when you add the gem to an app. Rails is assumed;
|
|
|
6
6
|
[not Rails?](#not-rails) covers the differences. Exploring an API from a console
|
|
7
7
|
instead? Start with [dynamic mode](real_world.md) — no build step. No Sorbet in
|
|
8
8
|
your app? None needed — [Sorbet, with or without](#sorbet-with-or-without).
|
|
9
|
+
Already have a GraphQL client? [Migrating](migrating.md) is the order to move
|
|
10
|
+
off it in.
|
|
9
11
|
|
|
10
12
|
## 1. Install
|
|
11
13
|
|
|
@@ -96,6 +98,11 @@ What it wrote:
|
|
|
96
98
|
marked "do not edit," so the output directory is added to `AllCops: Exclude:`.
|
|
97
99
|
An `AllCops:` you already have is left alone (a second one would replace it,
|
|
98
100
|
not merge); the generator prints the line to add.
|
|
101
|
+
- **`.gitattributes`** — `app/graphql/generated/** linguist-generated`, which is
|
|
102
|
+
how GitHub is told the same thing: it collapses those files in a pull-request
|
|
103
|
+
diff and leaves them out of the repository's language breakdown. Display only,
|
|
104
|
+
so they stay versioned and expandable
|
|
105
|
+
([the three tellings](generated_modules.md#make-your-tooling-treat-generated-as-generated)).
|
|
99
106
|
|
|
100
107
|
Rake needs no wiring: in Rails the `graph_weaver:*` tasks register themselves and
|
|
101
108
|
depend on `:environment`, so your initializer runs first. The generated modules
|
|
@@ -165,10 +172,11 @@ Fragment files hold only fragments (no operations), and names are unique across
|
|
|
165
172
|
them. Point elsewhere with `GraphWeaver.fragments_paths` (an appendable list,
|
|
166
173
|
default `app/graphql/fragments`).
|
|
167
174
|
|
|
168
|
-
One payoff: when a shared fragment *is* the whole selection on a
|
|
169
|
-
|
|
170
|
-
gets the same Ruby type — so
|
|
171
|
-
|
|
175
|
+
One payoff: when a shared fragment *is* the whole selection on a field, its type
|
|
176
|
+
is hoisted once into `GraphQLTypes` under the fragment's name and every query
|
|
177
|
+
that spreads it gets the same Ruby type — so a presenter can take a
|
|
178
|
+
`GraphQLTypes::PersonFields`, and a union's `case … T.absurd` works everywhere.
|
|
179
|
+
See [hoisting](generated_modules.md#a-shared-fragment-is-one-type).
|
|
172
180
|
|
|
173
181
|
## 4. Test against fakes
|
|
174
182
|
|
|
@@ -216,9 +224,10 @@ Five questions, five tasks — the last only on a federated graph:
|
|
|
216
224
|
Every one of them exits non-zero on a finding, so the gate is a chain.
|
|
217
225
|
`verify` compares the committed generated files against what the current schema
|
|
218
226
|
+ queries + registrations would produce, so it belongs in every CI build.
|
|
219
|
-
`schema:diff` asks whatever the dump came from —
|
|
220
|
-
`GRAPHWEAVER_AUTH` for private APIs),
|
|
221
|
-
[serves the schema itself](#your-apps-own-schema-in-process)
|
|
227
|
+
`schema:diff` asks whatever the dump came from — the source url the dump
|
|
228
|
+
recorded (with `GRAPHWEAVER_AUTH` for private APIs), your own schema class when
|
|
229
|
+
the app [serves the schema itself](#your-apps-own-schema-in-process), else the
|
|
230
|
+
client this graph's modules already post to — and
|
|
222
231
|
`rake graph_weaver:schema:refresh` is the repair either way. On an app with more
|
|
223
232
|
than one schema, `rake graph_weaver:graphs` lists which graphs are configured,
|
|
224
233
|
where each generates, and what each registers.
|
|
@@ -283,9 +292,11 @@ omits it. Any drift exits non-zero — whether a change matters is yours to judg
|
|
|
283
292
|
(`#breaking`, `#compatible`, `#to_h`, `#empty?`).
|
|
284
293
|
|
|
285
294
|
`queries:check` answers the question that matters when the schema *has* moved:
|
|
286
|
-
**which of your queries no longer validate, and why.** It re-introspects
|
|
287
|
-
|
|
288
|
-
|
|
295
|
+
**which of your queries no longer validate, and why.** It re-introspects
|
|
296
|
+
whatever is behind each graph's dump (without rewriting the dump) — the same
|
|
297
|
+
source `schema:refresh` rewrites from and `schema:diff` compares against — and
|
|
298
|
+
validates every `.graphql` file against the schema as it is right now, naming
|
|
299
|
+
each error's line and column:
|
|
289
300
|
|
|
290
301
|
```
|
|
291
302
|
app/graphql/queries/person.graphql
|
|
@@ -294,6 +305,12 @@ app/graphql/queries/person.graphql
|
|
|
294
305
|
1 invalid query
|
|
295
306
|
```
|
|
296
307
|
|
|
308
|
+
A dump that records no url has nothing to re-read, and so does a graph that
|
|
309
|
+
names its own schema: both are checked as they stand on disk. That is `verify`'s
|
|
310
|
+
question rather than this one's, so the passing verdict names the file instead of
|
|
311
|
+
claiming a check against the server ([a dump you already
|
|
312
|
+
have](#a-schema-dump-you-already-have)).
|
|
313
|
+
|
|
297
314
|
`GraphWeaver.check_queries` returns the same findings as data — a hash of file
|
|
298
315
|
to `message`/`line`/`column`, empty when everything validates — so you can wire
|
|
299
316
|
it into a spec, a Slack ping, an issue. Pass it `schema:` a *loaded* schema (not
|
|
@@ -331,15 +348,20 @@ app/graphql/queries/products.graphql: Products.sku — selected, never read (Cat
|
|
|
331
348
|
Each line names the query file, the selection to go and delete, and the
|
|
332
349
|
generated prop behind it. It reads the generated structs for the props a query
|
|
333
350
|
produced, then sweeps your `.rb`, `.rake`, `.builder`, `.erb`, `.slim`, `.haml`
|
|
334
|
-
and `.jbuilder`
|
|
335
|
-
`
|
|
336
|
-
|
|
351
|
+
and `.jbuilder` — plus Ruby carrying no extension, which is any name under
|
|
352
|
+
`bin/` or `exe/` and a ruby shebang anywhere else — for every name they could be
|
|
353
|
+
read by: `.sku`, `sku:`, `:sku`, `"sku"`. `PATHS=app,lib` narrows the sweep (a
|
|
354
|
+
`PATHS=` naming a directory that isn't there is refused rather than swept as
|
|
355
|
+
nothing); anything under a directory
|
|
337
356
|
named `generated`, plus `vendor`, `node_modules`, `tmp` and `log`, is skipped,
|
|
338
357
|
as is any file defining a graphql-ruby **type** — a `field :sku` there is your
|
|
339
358
|
*server* offering a field, not this app reading one back. A module handed whole
|
|
340
359
|
to a serializer (`render json:`, `to_h`, `as_json`, a local and all) counts every
|
|
341
|
-
prop as read, and the report quotes the line it followed
|
|
342
|
-
the
|
|
360
|
+
prop as read, and the report quotes the line it followed — a plain local stands
|
|
361
|
+
for the module only inside the method it was assigned in, so a same-named block
|
|
362
|
+
param in the next method credits nothing, while an `@ivar` crosses that boundary
|
|
363
|
+
the way a `before_action` does. Nothing is edited and the exit is 0;
|
|
364
|
+
`STRICT=1` exits 1 when anything is unread.
|
|
343
365
|
|
|
344
366
|
**It is a lint, not a proof**, and the task's own footer says so. It matches
|
|
345
367
|
names as text, so a prop called `name` counts as read the moment anything says
|
|
@@ -416,6 +438,34 @@ Sets `GraphWeaver.schema_path` to that file rather than writing a second copy,
|
|
|
416
438
|
and introspects nothing. A dump has no resolvers, so it can't execute — set
|
|
417
439
|
`GraphWeaver.client` to whatever serves the API.
|
|
418
440
|
|
|
441
|
+
**A dump you brought from elsewhere has no provenance**: it doesn't record the
|
|
442
|
+
server it was introspected from, which is exactly what migrating off
|
|
443
|
+
graphql-client leaves you ([migrating](migrating.md)). `rake
|
|
444
|
+
graph_weaver:schema:refresh` adopts it — with no url on the file it introspects
|
|
445
|
+
the client the graph names, rewrites the dump, and records the source, so every
|
|
446
|
+
later refresh and `schema:diff` re-read the right server. `URL=` names the
|
|
447
|
+
endpoint instead, if you'd rather say it once than configure the client first.
|
|
448
|
+
|
|
449
|
+
Until there is something behind the dump to re-read — the url it records, the
|
|
450
|
+
server the graph's client posts to, or the graphql-ruby class that client runs
|
|
451
|
+
in-process — `queries:check` validates against the committed file: a real check,
|
|
452
|
+
but `verify`'s question rather than this one's, and the verdict says which:
|
|
453
|
+
|
|
454
|
+
```
|
|
455
|
+
every query validates against db/schema.graphql as committed — not the server (rake graph_weaver:schema:diff asks whether the server moved)
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
A dump with none of those behind it *is* the schema — a hand-maintained SDL
|
|
459
|
+
nothing serves. All three tasks say so in one sentence:
|
|
460
|
+
|
|
461
|
+
```
|
|
462
|
+
db/schema.graphql records no source url and the graph names no server behind it — no client posting to one, and no graphql-ruby schema class in this process — so the file is the schema and nothing here can re-read it
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
`schema:refresh` steps over that graph and refreshes the rest; `schema:diff`
|
|
466
|
+
still exits 1, because a gate that passes on having compared nothing is worse
|
|
467
|
+
than one that says so.
|
|
468
|
+
|
|
419
469
|
## More than one schema
|
|
420
470
|
|
|
421
471
|
The five steps above describe one graph — a schema, its queries, its output. An
|
|
@@ -468,9 +518,10 @@ they belong to and nothing about transport, so they read it when they execute.
|
|
|
468
518
|
A graph with no `client` falls back to `GraphWeaver.client`, the app default.
|
|
469
519
|
Name the object (`client GraphWeaver.new(url, auth: …)`) or, when the constant
|
|
470
520
|
holding it is defined later than the graph block, its name (`client "GITHUB"`),
|
|
471
|
-
which is resolved on first use
|
|
472
|
-
|
|
473
|
-
|
|
521
|
+
which is resolved on first use — and not a lambda, which `schema` needs and this
|
|
522
|
+
doesn't, since the name is resolved at call time already. `schema "x"` sets and
|
|
523
|
+
a bare `schema` reads back; there is no `schema = "x"` form, since the block is
|
|
524
|
+
`instance_eval`'d and that would be a local variable that silently does nothing.
|
|
474
525
|
|
|
475
526
|
**`namespace` nests everything that graph generates** — `person.graphql` becomes
|
|
476
527
|
`Billing::PersonQuery` ([naming](generated_modules.md#naming)). Constants are
|
|
@@ -484,8 +535,10 @@ An app that is a pure client of someone else's GraphQL owns no schema class, so
|
|
|
484
535
|
every graph's `schema` is a dump. Give each the file you want and a `client`
|
|
485
536
|
that can fetch it: `rake graph_weaver:schema:refresh` introspects each graph's
|
|
486
537
|
client into its own dump, recording the url so every later `schema:refresh` and
|
|
487
|
-
`schema:diff` re-reads the right server.
|
|
488
|
-
dump
|
|
538
|
+
`schema:diff` re-reads the right server. Every graph is its own job — one with
|
|
539
|
+
no server behind its dump is left as checked in, one whose server is down is
|
|
540
|
+
reported under its own heading, and the rest still refresh. (`URL=` is for the
|
|
541
|
+
app that has one dump and no graphs.)
|
|
489
542
|
|
|
490
543
|
In specs, `graph:` is how an example says which graph a helper stands in for —
|
|
491
544
|
`graphql_fake(graph: :poke, "pokemon_v2_pokemon.name" => "pikachu")`. See
|
|
@@ -527,6 +580,106 @@ per-struct `extend T::Sig` in generated files is redundant — rubocop's
|
|
|
527
580
|
time and skips the `extend`; override with `GraphWeaver.extend_t_sig =
|
|
528
581
|
true`/`false`.
|
|
529
582
|
|
|
583
|
+
### The types stop where your sigs do
|
|
584
|
+
|
|
585
|
+
That typo is caught where the struct comes back. Hand the struct to one method
|
|
586
|
+
without a sig and it is `T.untyped` from there on — the same typo, one layer in,
|
|
587
|
+
is silent — and a sig on the method that produced it is no help either:
|
|
588
|
+
|
|
589
|
+
```ruby
|
|
590
|
+
# typed: true
|
|
591
|
+
class PersonDirectory
|
|
592
|
+
extend T::Sig
|
|
593
|
+
|
|
594
|
+
sig { returns(T.nilable(PersonQuery::Result::Person)) }
|
|
595
|
+
def person = PersonQuery.execute!(id: "1").person
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
class Profile
|
|
599
|
+
def initialize(person) # no sig, so @person is untyped
|
|
600
|
+
@person = person
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def title = @person.nmae # "No errors! Great job."
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
class Page
|
|
607
|
+
def initialize(directory) # nor is the sig above any help here
|
|
608
|
+
@directory = directory
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def title = @directory.person&.nmae # also clean
|
|
612
|
+
end
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
Every hop has to be sig'd: the method that produces the struct, any wrapper it
|
|
616
|
+
passes through, and the constructor that stored the collaborator. Two shapes
|
|
617
|
+
cover most of an app.
|
|
618
|
+
|
|
619
|
+
A wrapper that only passes a block through keeps the block's type with
|
|
620
|
+
`type_parameters`:
|
|
621
|
+
|
|
622
|
+
```ruby
|
|
623
|
+
# a rescue wrapper that keeps the block's type
|
|
624
|
+
sig do
|
|
625
|
+
type_parameters(:T).params(blk: T.proc.returns(T.type_parameter(:T)))
|
|
626
|
+
.returns(T.type_parameter(:T))
|
|
627
|
+
end
|
|
628
|
+
def people(&blk) = yield
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
A service boundary spells the struct it hands out, and whoever holds that
|
|
632
|
+
service types the ivar:
|
|
633
|
+
|
|
634
|
+
```ruby
|
|
635
|
+
class Page
|
|
636
|
+
extend T::Sig
|
|
637
|
+
|
|
638
|
+
sig { params(directory: PersonDirectory).void }
|
|
639
|
+
def initialize(directory)
|
|
640
|
+
@directory = directory # the sig is what types this ivar
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
sig { returns(T.nilable(String)) }
|
|
644
|
+
def title = @directory.person&.nmae # now srb tc has it
|
|
645
|
+
end
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
That is sigs on half a dozen service methods and a couple of constructors before
|
|
649
|
+
the first typo in a layered app is caught. Budget it as its own piece of work
|
|
650
|
+
rather than as part of generating the types — and if it isn't work you're going
|
|
651
|
+
to do, the runtime half still holds: a bad field is a `NoMethodError` the first
|
|
652
|
+
time the line runs.
|
|
653
|
+
|
|
654
|
+
### Give a struct a short name where you name one
|
|
655
|
+
|
|
656
|
+
A generated struct's constant path spells out the query that produced it, which
|
|
657
|
+
is what makes it stable — and long. Where your own code names one in a sig, alias
|
|
658
|
+
it once, in the class that hands it out:
|
|
659
|
+
|
|
660
|
+
```ruby
|
|
661
|
+
# before
|
|
662
|
+
sig { params(code: String).returns(T.nilable(Countries::CountryProfileQuery::Result::Country)) }
|
|
663
|
+
|
|
664
|
+
# after
|
|
665
|
+
class Directory
|
|
666
|
+
Country = Countries::CountryProfileQuery::Result::Country
|
|
667
|
+
|
|
668
|
+
sig { params(code: String).returns(T.nilable(Country)) }
|
|
669
|
+
end
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
Use a plain constant, not `T.type_alias`: the constant works in a sig *and* as a
|
|
673
|
+
value (`Country.from_h`), and in a `# typed: strict` file it needs no `T.let`
|
|
674
|
+
around it. Reach for `T.type_alias` only where a constant can't express the type
|
|
675
|
+
— a union of two queries' structs.
|
|
676
|
+
|
|
677
|
+
That takes the worst sig in one migrated app from 116 characters to 72, and the
|
|
678
|
+
callers get the short name too: a presenter takes a `Directory::Country` and
|
|
679
|
+
stops knowing which `.graphql` file produced it. It is also what makes the sig
|
|
680
|
+
chain above affordable, since a sig usually goes unwritten because the line is
|
|
681
|
+
too long to want to write.
|
|
682
|
+
|
|
530
683
|
## Not Rails?
|
|
531
684
|
|
|
532
685
|
There's no generator, but what it writes is short — a few lines wherever your
|
|
@@ -541,6 +694,15 @@ GraphWeaver.client = GraphWeaver.new(
|
|
|
541
694
|
GraphWeaver.load_generated! # no Railtie to require the generated files
|
|
542
695
|
```
|
|
543
696
|
|
|
697
|
+
**A public API needs no `auth:`** — leave the line out. It is in the example
|
|
698
|
+
because most endpoints want a token, not because the constructor asks for one.
|
|
699
|
+
|
|
700
|
+
**`load_generated!` goes before your own requires** when anything your app loads
|
|
701
|
+
names a generated constant as it loads — a `STATUS_LABELS` table keyed on
|
|
702
|
+
`GraphQLTypes::ShipmentStatus` raises `uninitialized constant` otherwise, and
|
|
703
|
+
building it lazily to dodge that earns `Dynamic constant references are
|
|
704
|
+
unsupported` from `srb tc`. One line of ordering settles both.
|
|
705
|
+
|
|
544
706
|
```sh
|
|
545
707
|
mkdir -p app/graphql/queries app/graphql/generated
|
|
546
708
|
rake graph_weaver:schema:refresh URL=https://api.example.com/graphql
|