graph_weaver 0.5.0 → 0.5.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 +128 -0
- data/Gemfile.lock +2 -2
- data/README.md +1 -1
- data/docs/cassettes.md +23 -3
- data/docs/errors.md +2 -0
- data/docs/federation.md +8 -7
- data/docs/generated_modules.md +2 -2
- data/docs/getting_started.md +1 -1
- data/docs/logging.md +1 -1
- data/docs/testing.md +8 -7
- data/docs/upgrading.md +34 -12
- data/graph_weaver.gemspec +16 -2
- data/lib/generators/graph_weaver/install_generator.rb +15 -15
- data/lib/graph_weaver/client.rb +6 -2
- data/lib/graph_weaver/codegen/aliases.rb +10 -4
- data/lib/graph_weaver/codegen/emit.rb +11 -3
- data/lib/graph_weaver/codegen/enum_type.rb +1 -3
- data/lib/graph_weaver/codegen/scalar_type.rb +5 -4
- data/lib/graph_weaver/codegen/type_helpers.rb +1 -3
- data/lib/graph_weaver/codegen.rb +99 -22
- data/lib/graph_weaver/errors.rb +27 -6
- data/lib/graph_weaver/federation.rb +4 -17
- data/lib/graph_weaver/parsing.rb +1 -9
- data/lib/graph_weaver/rspec.rb +13 -7
- data/lib/graph_weaver/schema_loader.rb +30 -6
- data/lib/graph_weaver/schemas.rb +4 -2
- data/lib/graph_weaver/tasks.rb +24 -21
- data/lib/graph_weaver/testing/cassette.rb +89 -20
- data/lib/graph_weaver/testing/coverage.rb +7 -12
- data/lib/graph_weaver/testing/failure.rb +4 -2
- data/lib/graph_weaver/testing/fake_client.rb +1 -1
- data/lib/graph_weaver/testing/router.rb +68 -47
- data/lib/graph_weaver/testing/subgraphs.rb +11 -7
- data/lib/graph_weaver/testing.rb +8 -2
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +39 -14
- metadata +8 -9
- 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/NOTES.md
DELETED
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
Research notes — graphql-client exploration → GraphWeaver
|
|
2
|
-
======
|
|
3
|
-
|
|
4
|
-
**What this is now:** a working prototype of a standalone, Sorbet-typed
|
|
5
|
-
GraphQL client for Ruby — "graphql-codegen for Ruby". `.graphql` queries +
|
|
6
|
-
a schema (live class, introspection JSON, or SDL) generate `# typed: strict`
|
|
7
|
-
Ruby: nested `T::Struct`s, casting code, and a typed `execute`, so `srb tc`
|
|
8
|
-
sees the exact shape of every query result. It is **not** a graphql-client
|
|
9
|
-
extension: generated code depends only on `graphql` (generation time) and
|
|
10
|
-
`sorbet-runtime` (runtime); transport is a pluggable `executor:` (in-process
|
|
11
|
-
schema or the bundled `HttpExecutor`).
|
|
12
|
-
|
|
13
|
-
Start with `PLAN.md` for current state and next steps. Key files:
|
|
14
|
-
`lib/struct_codegen.rb` (the generator), `queries/` → `bin/generate` →
|
|
15
|
-
`lib/generated/` (the build loop), `StructCodegen.load` (build-free dynamic
|
|
16
|
-
mode for development).
|
|
17
|
-
|
|
18
|
-
**How it got here:** the repo began as an exploration of
|
|
19
|
-
[graphql-client](https://github.com/github-community-projects/graphql-client)
|
|
20
|
-
internals — could its class-generation layer be swapped to emit custom
|
|
21
|
-
classes? (Yes: the `StructTypes` spike below.) The per-query codegen
|
|
22
|
-
approach then outgrew graphql-client entirely, and everything below the
|
|
23
|
-
next heading is preserved as the lab notebook: findings in chronological
|
|
24
|
-
order, each backed by a spec.
|
|
25
|
-
|
|
26
|
-
The specs are the documentation — each one asserts an observed behavior:
|
|
27
|
-
|
|
28
|
-
```sh
|
|
29
|
-
bundle exec rspec
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
## Findings, in exploration order
|
|
33
|
-
|
|
34
|
-
- The client runs fine against an in-process schema: `GraphQL::Client.new(schema: Schema, execute: Schema)` — no HTTP involved.
|
|
35
|
-
- Each query selection gets its own dynamically generated wrapper class (subclass of `GraphQL::Client::Schema::ObjectClass`); fields are snake_case readers, and unselected fields raise instead of returning nil.
|
|
36
|
-
- **Custom scalars are deserialized automatically** when the client is built from a live schema class: the reader casts wire values through the scalar's `coerce_input` (e.g. `"1990-06-15"` → `Date`). This is the built-in hook for producing rich Ruby values.
|
|
37
|
-
- Caveat: this only works with `schema:` as a live schema class. A schema loaded from an introspection JSON dump has no coercion logic, so scalars would stay raw.
|
|
38
|
-
- `to_h` returns the raw wire values (strings), not the casted ones — hydration code should read via the typed readers, not `to_h`.
|
|
39
|
-
- Hydrating into `T::Struct`s is straightforward manually; the interesting next step is generating the structs (or a generic hydrator) from the parsed query definition, since the client already knows each selection's shape and types.
|
|
40
|
-
|
|
41
|
-
## Swapping the class-generation layer (answered: yes)
|
|
42
|
-
|
|
43
|
-
`lib/struct_types.rb` + `spec/struct_types_spec.rb` prove the generation layer
|
|
44
|
-
can be replaced wholesale — the client deserializes straight into generated
|
|
45
|
-
`T::Struct`s, no `ObjectClass` involved.
|
|
46
|
-
|
|
47
|
-
How the pipeline hangs together (graphql-client 0.26.0):
|
|
48
|
-
|
|
49
|
-
- `Client#initialize` builds the types module: `@types = Schema.generate(schema)`
|
|
50
|
-
(`attr_reader :types`, no setter — swap via `instance_variable_set` or a subclass).
|
|
51
|
-
- `Client#parse` → `Definition#initialize` calls
|
|
52
|
-
`client.types.define_class(definition, ast_nodes, type)` and stores the result
|
|
53
|
-
as `definition.schema_class`. This is the ONLY thing the client asks of the
|
|
54
|
-
types module.
|
|
55
|
-
- `Client#query` → `definition.new(data, errors)` → `schema_class.new(data, errors)`.
|
|
56
|
-
- Everything below that is the `cast(value, errors)` protocol, composed
|
|
57
|
-
recursively per the query selection (NonNull/List wrappers, scalars, objects).
|
|
58
|
-
|
|
59
|
-
So the replacement contract is just:
|
|
60
|
-
- `define_class(definition, ast_nodes, type)` returning casters
|
|
61
|
-
- casters respond to `cast(value, errors)`
|
|
62
|
-
- the top-level caster must satisfy `Definition#new`'s case dispatch, which
|
|
63
|
-
tests `===` against the `GraphQL::Client::Schema::ObjectType` module —
|
|
64
|
-
including that module in your caster class is enough, plus a
|
|
65
|
-
`new(data, errors)` method
|
|
66
|
-
|
|
67
|
-
Gotchas found:
|
|
68
|
-
- the client injects `__typename` into every selection (`QueryTypename`), so a
|
|
69
|
-
custom generator must skip/handle `__`-prefixed fields
|
|
70
|
-
- scalar casting reuses the schema type's `coerce_isolated_input` — same hook
|
|
71
|
-
the stock `ScalarType` uses
|
|
72
|
-
- prop nullability comes for free from the type walk: everything is
|
|
73
|
-
`T.nilable` unless wrapped in NON_NULL
|
|
74
|
-
|
|
75
|
-
## Sorbet
|
|
76
|
-
|
|
77
|
-
- `sorbet` + `tapioca` are set up (`bundle exec srb tc` is green); rbis in `sorbet/rbi/gems`
|
|
78
|
-
- `struct_types.rb` typechecks at `# typed: true`
|
|
79
|
-
- generated structs are real `T::Struct`s: schema-derived prop types
|
|
80
|
-
(`T.nilable(Date)`, `T::Array[StructTypes::Pet]`) and runtime type
|
|
81
|
-
enforcement on bad wire data
|
|
82
|
-
|
|
83
|
-
## Codegen: srb tc sees query result types (answered: yes)
|
|
84
|
-
|
|
85
|
-
`lib/struct_codegen.rb` goes one step further than the runtime swap: it
|
|
86
|
-
emits plain `# typed: strict` Ruby source from a query + schema — nested
|
|
87
|
-
`T::Struct` classes, fully generated `from_h` casting code (no runtime
|
|
88
|
-
reflection), and a sig'd `execute`.
|
|
89
|
-
|
|
90
|
-
- source of truth: `queries/*.graphql`; regenerate with `bin/generate`
|
|
91
|
-
into `lib/generated/`; a spec asserts the checked-in output is current
|
|
92
|
-
- queries are validated against the schema at generation time
|
|
93
|
-
- `srb tc` statically checks result access end to end:
|
|
94
|
-
`result.person&.nmae` → `Method nmae does not exist on
|
|
95
|
-
PersonQuery::Result::Person`
|
|
96
|
-
- custom scalar deserialization is inlined by the generator
|
|
97
|
-
(`Date.iso8601(...)`) via a scalar registry; nullability and list
|
|
98
|
-
casting come from the NON_NULL/LIST walk
|
|
99
|
-
- note: generated `execute` runs against the schema directly, replacing
|
|
100
|
-
graphql-client at runtime entirely — the client's remaining value here
|
|
101
|
-
would be its HTTP adapter, which the generated code could target instead
|
|
102
|
-
|
|
103
|
-
## Fragments & unions (answered for codegen)
|
|
104
|
-
|
|
105
|
-
`queries/search.graphql` + `lib/generated/search_query.rb` exercise the
|
|
106
|
-
design:
|
|
107
|
-
|
|
108
|
-
- inline fragments and named fragment spreads are flattened into their
|
|
109
|
-
matching member's selection (exact type-name condition match; interface
|
|
110
|
-
conditions still open)
|
|
111
|
-
- unions emit a module per selection site: one `T::Struct` per possible
|
|
112
|
-
type, a `Type = T.type_alias { T.any(...) }`, and a `from_h` that
|
|
113
|
-
dispatches on `__typename` — codegen refuses union selections that
|
|
114
|
-
don't select `__typename`
|
|
115
|
-
- every possible type gets a member struct even without a fragment (it
|
|
116
|
-
still carries `__typename`), so dispatch is total
|
|
117
|
-
|
|
118
|
-
## Introspection / __type metadata
|
|
119
|
-
|
|
120
|
-
- `__type` / `__schema` queries work against the demo schema as expected
|
|
121
|
-
(see `spec/introspection_spec.rb` for the shapes)
|
|
122
|
-
- the key result: `GraphQL::Schema.from_introspection(Demo::Schema.as_json)`
|
|
123
|
-
produces a schema that codegen runs against **byte-identically** — so
|
|
124
|
-
generation works for remote APIs known only via an introspection dump.
|
|
125
|
-
Custom scalar handling survives because the codegen scalar registry is
|
|
126
|
-
keyed by type *name*, unlike runtime `coerce_input` which needs the live
|
|
127
|
-
schema class (the caveat that broke graphql-client's scalar casting)
|
|
128
|
-
|
|
129
|
-
## Federation / supergraph
|
|
130
|
-
|
|
131
|
-
- join__/link-annotated supergraph SDL parses via
|
|
132
|
-
`GraphQL::Schema.from_definition`, and codegen runs against it
|
|
133
|
-
unchanged — the directives are transparent to result typing
|
|
134
|
-
(`spec/federation_spec.rb` generates from a mini supergraph and casts a
|
|
135
|
-
response with no live subgraphs)
|
|
136
|
-
- gotcha: graphql-ruby's SDL builder does not apply directive-argument
|
|
137
|
-
defaults, so real Apollo `join v0.3` SDL (non-null defaulted args like
|
|
138
|
-
`extension: Boolean! = false`) fails to load unless those args are
|
|
139
|
-
provided or the directive defs are trimmed — a compatibility issue a
|
|
140
|
-
real tool would need to patch around
|
|
141
|
-
- client-side, federation needs nothing more: you query the router like
|
|
142
|
-
any schema. The *server-side* angle (emitting `@key`/`@external` via
|
|
143
|
-
apollo-federation) is a separate exploration — potentially relevant to
|
|
144
|
-
autographql
|
|
145
|
-
|
|
146
|
-
## Round 2: enums, interface conditions, loaders, dynamic mode, HTTP
|
|
147
|
-
|
|
148
|
-
- **enums** generate `T::Enum` classes (`Species::Dog`), deserialized via
|
|
149
|
-
`Species.deserialize(...)` in `from_h`; values sorted so output is
|
|
150
|
-
deterministic across schema sources
|
|
151
|
-
- **interface fragment conditions** (`... on Named { name }`) apply via
|
|
152
|
-
`schema.possible_types`, not just exact type-name match. Interface-typed
|
|
153
|
-
*fields* (a field returning `Named`) are still open — they'd emit like
|
|
154
|
-
unions with `__typename` dispatch
|
|
155
|
-
- **SchemaLoader** accepts both formats a remote service can hand you:
|
|
156
|
-
introspection dump (`.json`) or SDL (`.graphql`/`.gql`); both generate
|
|
157
|
-
byte-identically to the live schema class
|
|
158
|
-
- **dynamic mode**: `StructCodegen.load(...)` generates + evals in one
|
|
159
|
-
step — no build artifact, same runtime semantics, right for development
|
|
160
|
-
or one-off scripts. Tradeoff: the module is invisible to `srb tc`, so
|
|
161
|
-
static checking of result access needs the build step
|
|
162
|
-
- **HTTP transport**: generated `execute` takes `executor:` — anything
|
|
163
|
-
with `execute(query, variables:)` returning `{"data" => ...}`.
|
|
164
|
-
`HttpExecutor` (Net::HTTP POST) runs the same generated structs against
|
|
165
|
-
a live server (`spec/http_spec.rb` proves it against a local WEBrick
|
|
166
|
-
serving Demo::Schema)
|
|
167
|
-
- **directive defaults gap**: root cause found —
|
|
168
|
-
`BuildFromDefinition#prepare_directives` passes only usage-site args
|
|
169
|
-
while `Directive#initialize` validates all defined args without
|
|
170
|
-
applying `default_value`. `lib/directive_defaults_patch.rb` prepends
|
|
171
|
-
the fix; the federation spec now loads the *real* join v0.3 SDL.
|
|
172
|
-
Present in graphql 2.6.3 (latest) — worth an upstream issue/PR
|
|
173
|
-
|
|
174
|
-
## Open questions
|
|
175
|
-
|
|
176
|
-
- interface-typed fields (vs fragment conditions, which work)
|
|
177
|
-
- ~~name collisions~~ ANSWERED: path-based won. A generated type is named
|
|
178
|
-
for the response key that selects it, so the name is a function of the
|
|
179
|
-
field's own position — no walk order, no first-come-first-served, and an
|
|
180
|
-
unrelated selection can't move it. GraphQL aliases double as the explicit
|
|
181
|
-
naming escape hatch (`pet: pets` names the struct `Pet`)
|
|
182
|
-
- mutations/subscriptions (only query operations generate)
|
data/PLAN.md
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
# Project Plan — GraphWeaver, typed GraphQL client for Ruby/Sorbet
|
|
2
|
-
|
|
3
|
-
_Resume-from-here notes: where the project stands and what's next. The README
|
|
4
|
-
documents the product, CHANGELOG records what changed, DECISIONS.md records the
|
|
5
|
-
roads not taken, NOTES.md is the research notebook this grew out of. Update on
|
|
6
|
-
change._
|
|
7
|
-
|
|
8
|
-
## Vision
|
|
9
|
-
|
|
10
|
-
A "graphql-codegen for Ruby": `.graphql` queries + a schema (live class,
|
|
11
|
-
introspection JSON, SDL, or an Apollo supergraph) → checked-in `# typed: strict`
|
|
12
|
-
Ruby — nested `T::Struct`s, generated casting, a typed `execute` — so `srb tc`
|
|
13
|
-
sees the exact shape of every query result. Dynamic mode for consoles, a build
|
|
14
|
-
step for CI. Runtime deps: `graphql` + `sorbet-runtime`, nothing else.
|
|
15
|
-
|
|
16
|
-
## State
|
|
17
|
-
|
|
18
|
-
`0.4.6` on RubyGems. `main` carries a large unreleased body of work headed for
|
|
19
|
-
**0.5.0** — see `## Unreleased` in the CHANGELOG, which is long and has a real
|
|
20
|
-
upgrade story to tell.
|
|
21
|
-
|
|
22
|
-
Green gate is in `CLAUDE.md`; `make check` runs the core of it.
|
|
23
|
-
|
|
24
|
-
**What's built**, in brief — the CHANGELOG has the detail:
|
|
25
|
-
|
|
26
|
-
- **Codegen.** Queries and mutations, typed variable kwargs, fragments (inline,
|
|
27
|
-
named, shared across queries), unions and interfaces (a struct per named
|
|
28
|
-
condition plus a forward-compatible `Other`), enums as `T::Enum`, custom
|
|
29
|
-
scalars, `@skip`/`@include` nullability. Generated class names derive from the
|
|
30
|
-
response key, so they're stable under unrelated edits. Shared types live once
|
|
31
|
-
per schema in `GraphQLTypes`.
|
|
32
|
-
- **Sources.** Live schema class, introspection JSON, SDL, Apollo supergraph
|
|
33
|
-
(composition machinery stripped, `@inaccessible` subtracted to the API schema —
|
|
34
|
-
verified identical to Apollo's own `toAPISchema`), and raw subgraph SDL.
|
|
35
|
-
- **Transports.** `Transport::HTTP` (zero-dep, pooled, keep-alive) by default;
|
|
36
|
-
Faraday on explicit opt-in. `InProcess` wraps a live schema class with
|
|
37
|
-
`context:`, logging and branded errors. Composable `Retry` honouring
|
|
38
|
-
`Retry-After`. One instrumentation seam covering both paths.
|
|
39
|
-
- **Errors.** A typed `Response` envelope, an error hierarchy split by failure
|
|
40
|
-
site, field-level reporting with entity ids, `schema_stale?`, `#to_h`
|
|
41
|
-
throughout.
|
|
42
|
-
- **Testing.** Schema-correct fakes, failure simulation, anonymizing cassettes,
|
|
43
|
-
and an in-process federation router that runs real subgraph resolvers —
|
|
44
|
-
verified against a real `@apollo/gateway` (42 identical, 1 refused, 0 wrong),
|
|
45
|
-
refusing at plan time anything it can't answer faithfully. One rspec tag picks
|
|
46
|
-
the mode: `graphql: :fake | :in_process | :router`.
|
|
47
|
-
- **Lifecycle.** `generate` / `verify` / `schema:refresh` / `schema:diff` /
|
|
48
|
-
`queries:check` / `federation:diff`, plus `rails g graph_weaver:install`.
|
|
49
|
-
|
|
50
|
-
## Next
|
|
51
|
-
|
|
52
|
-
1. **Cut 0.5.0.** Needs an upgrade guide rather than a changelog dump — the
|
|
53
|
-
breaking list is long, but most of it is caught mechanically, so the guide is
|
|
54
|
-
largely *"regenerate, then follow `srb tc` and `verify_generated!`"*.
|
|
55
|
-
`gem push` needs an OTP.
|
|
56
|
-
2. **`extend_type`'s mixin forms can't be statically checked.** A mixin's method
|
|
57
|
-
bodies are checked in the module's scope, not the struct's, so the docs have
|
|
58
|
-
to recommend `# typed: false` or `T.unsafe(self)`. In a library whose pitch is
|
|
59
|
-
static checking, that's a seam worth a design pass. Note `alias:` — which
|
|
60
|
-
emits into the struct body — *is* checked, which suggests the mixin forms are
|
|
61
|
-
the ones carrying the cost.
|
|
62
|
-
3. **Nice-to-haves, unclaimed.** `write_timeout` on `Transport::HTTP` (and
|
|
63
|
-
possibly a `net_http:` passthrough rather than more kwargs); a Tapioca DSL
|
|
64
|
-
compiler so dynamic `parse` modules get static types without the build step.
|
|
65
|
-
|
|
66
|
-
## Federation router: what it still refuses
|
|
67
|
-
|
|
68
|
-
Each refuses at plan time with the type, field, subgraphs and next action. The
|
|
69
|
-
cost of moving each boundary, if a real query mix ever demands it:
|
|
70
|
-
|
|
71
|
-
- **`@requires` needing a chain** — the prefetch's own key must come from the
|
|
72
|
-
subgraph in hand; needs a real dependency DAG.
|
|
73
|
-
- **An abstract type the supergraph doesn't break down** — a union or interface
|
|
74
|
-
at a boundary now plans, one branch per concrete type, bucketed on
|
|
75
|
-
`__typename` at execution. What is left is the supergraph that doesn't say
|
|
76
|
-
which concrete types a subgraph answers it with — no
|
|
77
|
-
`@join__unionMember`/`@join__implements`, and the type in more than one
|
|
78
|
-
subgraph. Closing it means reading a join version that predates those
|
|
79
|
-
directives; a modern composition always carries them.
|
|
80
|
-
- **A nested field set no one fetch can build** — a nested field set now
|
|
81
|
-
crosses as the object it is, to any depth. What is left is the one whose
|
|
82
|
-
fields are split across subgraphs (`origin` in one and `origin.lat` in
|
|
83
|
-
another, or a `@key`'s object a `@requires` would half-fill from
|
|
84
|
-
elsewhere): a representation comes from one fetch, so the object would
|
|
85
|
-
arrive in pieces. Closing it means merging the pieces, which
|
|
86
|
-
`DECISIONS.md` argues against — the shapes that produce a split are the
|
|
87
|
-
ones where a real gateway stops being an oracle.
|
|
88
|
-
- **Mutation root fields spanning subgraphs** — root mutation fields run in
|
|
89
|
-
series, so grouping them would run them in plan order.
|
|
90
|
-
- **An alias shadowing an injected `@key`** — Apollo resolves the collision in
|
|
91
|
-
favour of its own key and a spec-conformant server doesn't, so there is no one
|
|
92
|
-
answer to agree with. Unfixable by design.
|
|
93
|
-
|
|
94
|
-
`rake graph_weaver:federation:coverage` reports the refusal rate against a real
|
|
95
|
-
supergraph and query set. That number decides whether any of the above is worth
|
|
96
|
-
building — on the demo corpus it is 17/17.
|
|
97
|
-
|
|
98
|
-
## Stated non-goals
|
|
99
|
-
|
|
100
|
-
Recorded so they read as decisions rather than omissions, with the reasoning in
|
|
101
|
-
`REVIEW.md` §7: subscriptions, `@defer`/`@stream`, file uploads, normalized
|
|
102
|
-
caching, fragment masking, request batching, and a watch mode.
|
|
103
|
-
|
|
104
|
-
## Gotchas worth remembering
|
|
105
|
-
|
|
106
|
-
- graphql-ruby's `to_definition`/`from_introspection` reorder enum values and
|
|
107
|
-
possible types — codegen sorts both; keep any new emission deterministic.
|
|
108
|
-
- Schemas built from introspection or SDL have no scalar coercion or resolvers,
|
|
109
|
-
so codegen stays name-keyed and never calls schema runtime hooks.
|
|
110
|
-
- A `SchemaDefinition` node reprints without its body when the root type names
|
|
111
|
-
are the GraphQL defaults, so directives on `schema` must be stripped before
|
|
112
|
-
reprinting a supergraph.
|
|
113
|
-
- Code the build doesn't exercise rots silently — integration specs excluded from
|
|
114
|
-
the default run, examples the generator skips, doc samples nobody executes.
|
|
115
|
-
Six fabricated doc samples were found in one session by running them.
|