graph_weaver 0.4.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1314 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -154
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -258
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -124
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +593 -334
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +9 -1
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +14 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +7 -0
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +794 -59
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +43 -8
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +160 -61
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +181 -21
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +28 -10
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +67 -14
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +389 -170
  62. metadata +20 -3
data/PLAN.md CHANGED
@@ -1,144 +1,115 @@
1
1
  # Project Plan — GraphWeaver, typed GraphQL client for Ruby/Sorbet
2
2
 
3
- _Resume-from-here notes. README documents the product, NOTES.md is the
4
- research notebook this grew out of; this is the plan. Update on change._
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._
5
7
 
6
8
  ## Vision
7
9
 
8
10
  A "graphql-codegen for Ruby": `.graphql` queries + a schema (live class,
9
- introspection JSON, or SDL) → checked-in `# typed: strict` Ruby — nested
10
- T::Structs, generated casting, typed execute — so `srb tc` sees the exact
11
- shape of every query result. Dynamic (eval) mode for development, build
12
- step for CI/static checking. Runtime deps: graphql + sorbet-runtime only
13
- (graphql-client is NOT a dependency; the exploration outgrew it).
14
-
15
- ## State: v0.0.1 on rubygems; v0.0.2 accumulating on main
16
-
17
- `make check` = bin/generate (spec fixture regeneration; parity specs
18
- enforce freshness) + rspec + srb tc.
19
-
20
- Language coverage: queries, mutations, typed variables (kwargs on
21
- execute, optional-when-defaulted), fragments (inline, named, interface
22
- conditions), union- AND interface-typed fields (__typename dispatch,
23
- required at generation time), enums (T::Enum), custom scalars via the
24
- ScalarType registry (register_scalar: codec inference off .parse/.load,
25
- requires:, opt-in input coercion incl. built-ins).
26
- Sources: live schema / introspection JSON / SDL — byte-identical output
27
- (enum values + abstract-type members sorted for determinism).
28
- Transport: executor precedence per call per module baked const
29
- GraphWeaver.executor; Transport::HTTP (zero-dep) + opt-in Transport::Faraday
30
- (url / block middleware / ready connection), e2e specs against WEBrick.
31
- Errors: typed Response envelope (partial data + extensions survive) and
32
- a GraphWeaver::Error hierarchy (Transport/Server/Query/Validation/Type)
33
- with extensible transport-error classification, schema_stale? staleness
34
- detection, errors_at/each_error/report field-level surfacing (entity
35
- ids resolved from partial data), and #to_h machine output throughout.
36
- Dynamic mode: GraphWeaver.parse (paths or raw strings, derived names,
37
- container-scoped constants) and GraphWeaver.execute one-shots.
38
- Schema fetching: SchemaLoader.introspect(executor, cache:, ttl:) off
39
- live endpoints; load takes paths, content, or Hashes.
40
- Testing (graph_weaver/testing + graph_weaver/rspec): FakeExecutor
41
- (schema-correct castable fakes, faker semantics, GraphQL-name
42
- overrides, corrupt:, fail_at: with null propagation), Failure canned
43
- executors + SequenceExecutor, cassettes with shape-preserving
44
- anonymization, rspec seed + auto_fake integration. Selection module is
45
- the single shared query-walk (codegen/fake/anonymizer).
46
- Federation supergraph SDL loads transparently (needs
47
- directive_defaults_patch until upstream fix ships).
48
- Live validation: make integration (GitHub + Countries APIs).
49
-
50
- ## Next steps (in rough order)
51
-
52
- ~~Extraction~~ DONE 2026-07-07: this repo IS the gem now — GraphWeaver,
53
- github.com/dpep/graph_weaver, rspec-uuid conventions throughout. The
54
- graphql-client spikes live in git history (tag: `exploration`) and
55
- NOTES.md. Prior-art check partially answered: graphql-client PR #7
56
- (tapioca compiler over schema-wide dynamic classes) stalled since
57
- Jan 2024 with users asking; schema-wide typing can't catch
58
- unfetched-field bugs or type unions/interfaces — the niche looks open.
59
-
60
- ~~Input objects~~ DONE 2026-07-11: module-level T::Structs, serialize/
61
- to_h, hash coercion at the execute boundary.
62
- ~~Release~~ 0.1.0 cut 2026-07-11 (breaking: execute returns the
63
- Response envelope; execute! for raise-or-result).
64
-
65
- 1. Stable class naming design — names come from GraphQL type names per
66
- selection site; must not shift when unrelated selections are added
67
- (generated code is app-code API). Current: one-level field-name
68
- disambiguation, then raise. Shipped in 0.1.0 as-is — a naming change
69
- is fair game pre-1.0 but should land early.
70
- 2. CLI entrypoint (graph_weaver generate --schema X --queries dir) —
71
- bin/generate is spec-fixture tooling, not shipped.
72
- 3. Subscriptions (unsupported; raise). Recursive input types (raise).
73
- 6. Parse/execute memoization: repeated GraphWeaver.parse/execute of the
74
- same [schema, query] re-generates and re-evals every call (~3x the
75
- cost of a cached module; benchmarked 2026-07-09) — memo keyed on
76
- schema/query/name/executor, minding shared executor= mutation.
77
- (Schema-side caching landed: SchemaLoader.introspect cache:/ttl: +
78
- introspection_result primitive for Rails.cache et al. Possible
79
- follow-up: re-introspect + retry once on validation-shaped
80
- QueryErrors, since GraphQL has no standard schema-version signal.)
81
- 7. Nice-to-haves: __typename auto-injection (currently required manually
82
- on abstract selections), fragment reuse across queries, directives on
83
- selections (@skip/@include make non-null fields nullable).
84
- 8. Tapioca DSL compiler over dynamic mode (idea from graphql-client
85
- PR #7): RBI the GraphWeaver.parse-eval'd modules so development mode
86
- gets static types without the bin/generate build step tapioca is
87
- already in every Sorbet shop's workflow. Upstream's
88
- Tapioca::Dsl::Helpers::GraphqlTypeHelper is prior art for type mapping.
89
-
90
- ## External dependencies
91
-
92
- - rmosolgo/graphql-ruby#5659 (directive-argument defaults fix; our branch
93
- `directive-argument-defaults` in ~/code/lib/ruby/graphql, pushed to the
94
- dpep fork, PR in draft). When it ships in a release: bump graphql,
95
- delete lib/graph_weaver/directive_defaults_patch.rb + its requires (TODO in file).
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.
96
103
 
97
104
  ## Gotchas worth remembering
98
105
 
99
- - graphql-ruby to_definition/from_introspection reorder enum values and
100
- possible_types — codegen sorts both; keep any new emission deterministic
101
- - schemas built from introspection/SDL have no scalar coercion or
102
- resolvers codegen must stay name-keyed, never call schema runtime hooks
103
- - graphql-client (the gem) casts scalars via coerce_isolated_input and
104
- only with a live schema class documented in the early specs
105
-
106
- ## From the field-test experiments (2026-07-12)
107
-
108
- Generated-module size (2026-07-12, post-0.2.0): input structs are now
109
- table-driven (InputStruct runtime + FIELDS) — the PokeAPI filtered-query
110
- module dropped 29,233 -> 11,562 lines. The floor is ~2 lines/field
111
- (typed const + FIELDS entry). Remaining lever if it matters again:
112
- shared input structs emitted once per schema instead of per module.
113
-
114
-
115
- A junior + senior agent pair exercised the repo cold (clone, examples,
116
- extensions, a Pokedex app against Hasura's 4,441-type PokeAPI schema).
117
- Fixed same-day: snake_case type names generated invalid constants
118
- (camelize), GraphQL::ParseError/NotImplementedError escaping the Error
119
- umbrella, HttpExecutor timeouts, GitHub's top-level "type" error codes,
120
- typo'd client registrations silently no-oping. Still open:
121
-
122
- ~~Recursive input types~~ DONE 2026-07-12: register-before-walk breaks
123
- the recursion; emission dependency-orders the structs and, for cycles,
124
- forward-declares the classes in an eval (srb rejects reopening a
125
- T::Struct statically, but adding props at runtime works — the full
126
- bodies below the eval are all srb sees). Verified live against Hasura
127
- bool_exp.
128
-
129
- ~~Connection reuse in Transport::HTTP~~ DONE 2026-07-12: persistent
130
- keep-alive connection behind a mutex, dropped on any failure; net/http's
131
- keep_alive_timeout handles idle expiry. Faraday remains the pooling
132
- answer.
133
-
134
- - Subscriptions; @defer/@stream (routers send multipart responses — the
135
- transport classifies them as ServerError today, no incremental support).
136
- - Shared input-type structs across generated modules: one Hasura
137
- bool_exp variable pulls its whole recursive closure into EVERY module
138
- (~28k lines each) — correct but heavy in PRs; needs cross-module
139
- sharing or selection-based pruning (field-test round 2).
140
- - Structured logging: log_tag pairs lines and names operations now, but
141
- events are prose — an optional {event:, url:, ms:} payload contract, a
142
- scrub_variables hook, and cache-age on hit lines (field-test round 2).
143
- - Cut 0.1.1 — RubyGems 0.1.0 is materially behind main (snake_case fix,
144
- recursive inputs, keep-alive, logging, error umbrella).
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.
data/README.md CHANGED
@@ -3,14 +3,19 @@ GraphWeaver
3
3
  ![Gem](https://img.shields.io/gem/dt/graph_weaver?style=plastic)
4
4
  [![codecov](https://codecov.io/gh/dpep/graph_weaver/branch/main/graph/badge.svg)](https://codecov.io/gh/dpep/graph_weaver)
5
5
 
6
- A typed GraphQL client for Ruby, built for federation, extensibility, Sorbet, and testing.
6
+ **Your `.graphql` files, compiled into Sorbet types and the fakes to test them.**
7
7
 
8
- GraphWeaver generates `# typed: strict` Ruby from your queries: nested `T::Struct`s, casting code, and a typed `execute` so `srb tc` sees the exact shape of every query result, and a typo'd field is a static error, not a runtime surprise.
8
+ GraphWeaver is graphql-codegen for Ruby. Write a query as a `.graphql` file and it
9
+ generates checked-in `# typed: strict` Ruby — nested `T::Struct`s, casting, a typed
10
+ `execute` — so `srb tc` knows the exact shape of every result. The schema can be a
11
+ live graphql-ruby class, an introspection dump, SDL, or an Apollo supergraph;
12
+ at runtime the only dependencies are `graphql` and `sorbet-runtime`.
9
13
 
10
14
  ```graphql
11
- # queries/person.graphql
15
+ # app/graphql/queries/person.graphql
12
16
  query($id: ID!) {
13
17
  person(id: $id) {
18
+ id
14
19
  name
15
20
  birthday
16
21
  pets { name }
@@ -18,132 +23,146 @@ query($id: ID!) {
18
23
  }
19
24
  ```
20
25
 
26
+ `rake graph_weaver:generate` turns that file into a `PersonQuery` module, and what
27
+ comes back is a struct rather than a Hash you have to trust:
28
+
21
29
  ```ruby
22
- result = PersonQuery.execute!(id: "1") # typed result, or raises on errors (execute returns an envelope)
30
+ result = PersonQuery.execute!(id: "1") # or #execute, for the Response envelope
23
31
 
24
- result.person&.name # => "Daniel" (typed String)
25
- result.person&.birthday # => Date (custom scalars deserialize)
26
- result.person&.nmae # => srb tc: Method `nmae` does not exist
27
- ```
32
+ result.person&.name # => "Daniel"
33
+ result.person&.birthday # => #<Date: 1990-06-15> custom scalars deserialize
34
+ result.person&.pets&.map(&:name) # => ["Shelby", "Brownie"]
28
35
 
29
- New here? The **[getting started](docs/getting_started.md)** guide walks the
30
- production setup end to end initializer, codegen, fakes, CI. Or run the
31
- **[examples](examples/)**, smallest first: `examples/countries.rb` (public
32
- API, no auth, all dynamic), `examples/rick_and_morty.rb` (filtering,
33
- pagination, a block-built type helper), and `examples/github/run.rb`
34
- (auth + checked-in generated modules; it stars this repo ⭐ and introduces
35
- you to your fellow stargazers).
36
+ result.person&.nmae
37
+ # srb tc: Method `nmae` does not exist on `PersonQuery::Result::Person`
38
+ # Did you mean `name`?
39
+ ```
36
40
 
37
- #### Features
41
+ `person` is `T.nilable` because the schema says the field is nullable — the `&.`
42
+ isn't defensive, it's the schema talking. A field you misspelled, or never
43
+ selected, is a typecheck error rather than a `NoMethodError` in production.
38
44
 
39
- - **Queries and mutations** with typed variable kwargs enums as `T::Enum`s, input objects as `T::Struct`s, required vs optional falling out of nullability and defaults
40
- - **Fragments** (inline, named, type conditions), **unions and interfaces** (member structs, `__typename` dispatch), **custom scalars** (pluggable registry), `@skip`/`@include` nullability
41
- - **Any schema source**: live schema class, introspection JSON, or SDL — including Apollo Federation supergraph SDL; introspect live endpoints with caching
42
- - **Any transport**: in-process schema execution, the zero-dependency HTTP executor, or Faraday with your own middleware — plus a composable `Retry` (exponential/linear/custom backoff, jitter, retry-by-error-class or GraphQL code) — swap per call with `executor:`
43
- - **Structured errors**: a typed response envelope (partial data + extensions survive), an error hierarchy split by failure site, field-level reports with entity ids, and `schema_stale?` detection — every error dual-surfaced as a human message plus JSON-ready `#to_h`
44
- - **Testing built in**: schema-correct fakes, failure simulation, record/replay cassettes with anonymization, rspec integration
45
- - **Dynamic mode** for development: `GraphWeaver.parse(...)` generates and evals on the fly, no build step
45
+ Typed structs are the part every generator gets right. What decides whether you're
46
+ still happy six months in is everything around them.
46
47
 
47
- #### Usage
48
+ ## Precise types are expensive to fake, so it fakes them for you
48
49
 
49
- Three ways to run a query pick by context:
50
+ Generation makes result types exact, which makes them tedious to build by hand —
51
+ and most generators stop there and leave you the fixtures. GraphWeaver ships the
52
+ fabricator. One line in the spec helper:
50
53
 
51
- | Context | Use |
52
- |---------|-----|
53
- | Production | checked-in codegen (`rake graph_weaver:generate`) — reviewed, `srb tc`-checked |
54
- | Development, consoles | `client.parse` / `client.load_queries!` — no build step |
55
- | Scripts, one-offs | `client.execute!` — no module at all |
54
+ ```ruby
55
+ require "graph_weaver/rspec"
56
+ ```
56
57
 
57
- The production path assembled is the [getting started](docs/getting_started.md);
58
- the pieces:
58
+ then one tag says what an example runs against:
59
59
 
60
60
  ```ruby
61
- require "graph_weaver"
62
-
63
- # a client for one server: transport (Faraday when loaded), auth, and a
64
- # lazily introspected schema. The first argument is a url or any schema
65
- # source a live schema class, or a .json/.graphql dump
66
- api = GraphWeaver.new("https://api.example.com/graphql", auth: ENV["API_TOKEN"], cache: true)
67
-
68
- # make it the app default — generated modules execute through it
69
- GraphWeaver.client = api
70
-
71
- # generate checked-in typed modules (rake graph_weaver:generate, or directly)
72
- source = GraphWeaver::Codegen.generate(
73
- schema: api.schema,
74
- query: File.read("queries/person.graphql"),
75
- module_name: "PersonQuery",
76
- )
77
- File.write("app/queries/person_query.rb", source)
78
-
79
- # at runtime
80
- PersonQuery.execute(id: "1") # via GraphWeaver.client
81
- PersonQuery.execute(other_client, id: "1") # or per call
61
+ it "shows the profile", graphql: :fake do
62
+ person = PersonQuery.execute!(id: "1").person
63
+
64
+ person.name # => "Shakita Stark" fabricated from your schema
65
+ person.birthday # => #<Date: 2024-12-16> custom scalars included
66
+ person.pets.size # => 2
67
+ end
82
68
  ```
83
69
 
84
- Module names derive from the operation name (`query GetPerson`
85
- `GetPerson`) or, for `parse` on a `.graphql` file, from the file name;
86
- pass `module_name:`/`name:` to override. Pass `client:` (a constant) to
87
- bake a default client into the generated module. Prefer Faraday? It's
88
- opt-in (`gem "faraday"`), and the client picks it up when loaded —
89
- middleware blocks and ready connections in [transports](docs/transports.md).
70
+ No fixture, no stub, no HTTP — and the values are seeded from rspec's own seed, so
71
+ `--seed 4242` hands back that same person and a failure reproduces. The tag also
72
+ picks a *real* client when you want one: `:in_process` runs your resolvers,
73
+ `:router` runs them across a federated graph. Field-level failure simulation and
74
+ record/replay cassettes with anonymization are in [testing](docs/testing.md).
90
75
 
91
- In development, skip the build step entirely — modules from `client.parse`
92
- carry the client's transport, no global wiring needed:
76
+ ## Federation without a gateway
93
77
 
94
- ```ruby
95
- # parse a query into a typed module on the fly a .graphql path or a raw string
96
- PersonQuery = api.parse("queries/person.graphql")
97
- PersonQuery.execute(id: "1")
78
+ When your app is both a GraphQL client and a subgraph, the local router plans a
79
+ query across the composed supergraph and runs your **real resolvers** over the
80
+ boundary no gateway process, no node, no sockets. That's
81
+ [`examples/federation.rb`](examples/federation.rb), the example that needs no network:
82
+
83
+ ```
84
+ $ bundle exec examples/federation.rb
85
+ #<GraphWeaver::Testing::Router subgraphs=["accounts", "products", "reviews"]>
98
86
 
99
- # or every query file at once (queries_path convention), named like generation would
100
- api.load_queries!
87
+ dpep reviewed 2 products:
88
+ Table ($899) — Love it
89
+ Couch ($1299) — Too expensive
101
90
 
102
- # or one-shot, no module at all — variables are plain kwargs
103
- api.execute!("query($id: ID!) { person(id: $id) { name } }", id: "1")
91
+ fetches:
92
+ accounts root fields
93
+ → reviews _entities × 1 User
94
+ → products _entities × 2 Product
104
95
  ```
105
96
 
97
+ The trace is the query plan: every node at a level in one `_entities` call, so two
98
+ products cost one fetch. Anything it can't answer *faithfully* it refuses at plan
99
+ time rather than guessing — and it's diffed against a real `@apollo/gateway` over
100
+ the same supergraph, currently 42 queries identical, 1 refused, 0 wrong
101
+ ([`spec/integration/router_parity_spec.rb`](spec/integration/router_parity_spec.rb)).
102
+ See [federation](docs/federation.md).
106
103
 
107
- #### Dig deeper
104
+ ## The schema keeps itself honest
108
105
 
109
- - **[Getting started](docs/getting_started.md)** the production path in Rails,
110
- step by step: initializer, rake tasks, fakes, CI, Sorbet or not
111
- - **[Generated modules](docs/generated_modules.md)** module anatomy, typed
112
- variables (enums, input objects), fragments/unions/interfaces,
113
- `@skip`/`@include`, naming, clients, dynamic mode
114
- - **[Against a real API](docs/real_world.md)** — the exploratory tour:
115
- introspect a live endpoint (GitHub end to end), dynamic mode, schema caching
116
- - **[Federation](docs/federation.md)** — Apollo Federation: supergraph vs API
117
- schema, feeding weaver a composed graph, the `@inaccessible` caveat
118
- - **[Transports](docs/transports.md)** — clients, the execute contract,
119
- Faraday, retries and backoff
120
- - **[Custom scalars](docs/scalars.md)** — the registry: codec inference,
121
- requires, input coercion
122
- - **[Errors](docs/errors.md)** — the Response envelope, the error hierarchy,
123
- field-level reports with entity ids, stale-schema detection
124
- - **[Logging](docs/logging.md)** — point `GraphWeaver.logger` at any Logger:
125
- wire traffic at debug, introspection/cache/codegen at info, errors at warn
126
- - **[Testing](docs/testing.md)** — schema-correct fakes, failure simulation,
127
- rspec integration
128
- - **[Cassettes](docs/cassettes.md)** — capture and replay real API
129
- responses; anonymized recording (`GRAPHWEAVER_RECORD=1`, rake tasks)
106
+ The lifecycle is rake tasks, not a CI pipeline you assemble yourself:
107
+ `schema:refresh` re-introspects the committed dump, `schema:diff` fails when the
108
+ server has drifted, `queries:check` names the queries that drift broke and where,
109
+ and `verify` fails when the checked-in Ruby is stale. Generation is deterministic
110
+ same schema and queries, byte-identical files — so regenerating never shows a
111
+ diff you didn't earn. See [getting started](docs/getting_started.md#5-verify-in-ci).
130
112
 
131
- ----
132
- ## Installation
113
+ ## Start here
133
114
 
134
115
  ```ruby
135
116
  # Gemfile
136
117
  gem "graph_weaver"
137
118
  ```
138
119
 
139
- or
120
+ In Rails, setup is then one command:
140
121
 
141
122
  ```sh
142
- gem install graph_weaver
123
+ rails g graph_weaver:install https://api.example.com/graphql
143
124
  ```
144
125
 
126
+ which writes the initializer, the `app/graphql` layout, the editor config and the
127
+ schema dump. **[Getting started](docs/getting_started.md)** walks the production
128
+ setup end to end. Or skip the build step entirely and poke at an API from a
129
+ console — anything holding a schema parses, and the module runs on what parsed it:
130
+
131
+ ```ruby
132
+ api = GraphWeaver.new("https://countries.trevorblades.com/")
133
+ CountryQuery = api.parse("queries/country.graphql") # a path or a raw string
134
+ CountryQuery.execute!(code: "JP").country&.capital # => "Tokyo"
135
+
136
+ api.run!("query { continents { name } }").continents # or no module at all
137
+ ```
138
+
139
+ The **[examples](examples/)** run that path for real, smallest first: a public API
140
+ in 30 lines, a paginated search, the production path against GitHub, and the
141
+ federated graph above.
142
+
143
+ #### Also in the box
144
+
145
+ - **Queries and mutations** with typed variable kwargs — enums as `T::Enum`s, input objects as `T::Struct`s, required vs optional falling out of nullability and defaults
146
+ - **Fragments** (inline, named, type conditions), **unions and interfaces** (member structs, `__typename` dispatch), `@skip`/`@include` nullability
147
+ - **Any transport**: in-process execution, a zero-dependency HTTP client, or Faraday with your own middleware — plus a composable `Retry` with backoff and jitter
148
+ - **Structured errors**: a typed envelope that keeps partial data and extensions, an error hierarchy split by failure site, field-level reports with entity ids, and stale-schema detection
149
+
150
+ #### Dig deeper
151
+
152
+ - **[Getting started](docs/getting_started.md)** — the production path in Rails, step by step
153
+ - **[Generated modules](docs/generated_modules.md)** — module anatomy, typed variables, fragments/unions/interfaces, naming, clients, dynamic mode
154
+ - **[Testing](docs/testing.md)** — fakes, failure simulation, the rspec tags
155
+ - **[Federation](docs/federation.md)** — supergraph vs API schema, the local router, what it refuses
156
+ - **[Transports](docs/transports.md)** — the execute contract, Faraday, retries and backoff
157
+ - **[Errors](docs/errors.md)** — the Response envelope, the error hierarchy, field-level reports
158
+ - **[Custom scalars](docs/scalars.md)** — the registry: codec inference, requires, input coercion
159
+ - **[Cassettes](docs/cassettes.md)** — capture and replay real responses, anonymized
160
+ - **[Editor support](docs/editors.md)** — five lines of YAML for schema autocomplete in `.graphql` files, no JS project
161
+ - **[Against a real API](docs/real_world.md)** — introspecting a live endpoint, GitHub end to end
162
+ - **[Logging](docs/logging.md)** — point `GraphWeaver.logger` at any Logger
163
+
145
164
  ----
146
165
  ## Development
147
166
 
148
167
  - `make check` — regenerate spec fixtures, run specs, typecheck
149
- - `make integration` — one-off checks against the live GitHub and Countries APIs
168
+ - `make integration` — one-off checks against live APIs (GitHub needs a token) and a federation gateway (needs node)