graph_weaver 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +75 -54
  5. data/docs/cassettes.md +6 -1
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +73 -16
  8. data/docs/federation.md +201 -151
  9. data/docs/generated_modules.md +222 -165
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +34 -4
  12. data/docs/real_world.md +4 -4
  13. data/docs/scalars.md +206 -46
  14. data/docs/testing.md +191 -151
  15. data/docs/transports.md +47 -19
  16. data/docs/upgrading.md +210 -11
  17. data/lib/generators/graph_weaver/install_generator.rb +16 -1
  18. data/lib/graph_weaver/client.rb +46 -13
  19. data/lib/graph_weaver/codegen/aliases.rb +5 -4
  20. data/lib/graph_weaver/codegen/emit.rb +96 -39
  21. data/lib/graph_weaver/codegen/enum_type.rb +3 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +167 -98
  24. data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
  25. data/lib/graph_weaver/codegen.rb +279 -84
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +30 -7
  28. data/lib/graph_weaver/federation.rb +6 -5
  29. data/lib/graph_weaver/hints.rb +76 -2
  30. data/lib/graph_weaver/in_process.rb +11 -8
  31. data/lib/graph_weaver/inflect.rb +2 -0
  32. data/lib/graph_weaver/input_struct.rb +115 -12
  33. data/lib/graph_weaver/internal/overrides.rb +101 -0
  34. data/lib/graph_weaver/internal/planner.rb +868 -0
  35. data/lib/graph_weaver/internal/schemas.rb +50 -0
  36. data/lib/graph_weaver/internal/selection.rb +127 -0
  37. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
  38. data/lib/graph_weaver/internal/values.rb +184 -0
  39. data/lib/graph_weaver/internal.rb +206 -0
  40. data/lib/graph_weaver/logging.rb +108 -20
  41. data/lib/graph_weaver/parsing.rb +5 -4
  42. data/lib/graph_weaver/query_module.rb +2 -0
  43. data/lib/graph_weaver/railtie.rb +113 -14
  44. data/lib/graph_weaver/representation.rb +30 -2
  45. data/lib/graph_weaver/response.rb +15 -0
  46. data/lib/graph_weaver/retry.rb +54 -22
  47. data/lib/graph_weaver/rspec.rb +50 -11
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +96 -29
  50. data/lib/graph_weaver/tasks.rb +78 -29
  51. data/lib/graph_weaver/testing/cassette.rb +49 -65
  52. data/lib/graph_weaver/testing/coverage.rb +5 -4
  53. data/lib/graph_weaver/testing/failure.rb +10 -6
  54. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  55. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  56. data/lib/graph_weaver/testing/router.rb +94 -808
  57. data/lib/graph_weaver/testing.rb +35 -84
  58. data/lib/graph_weaver/transport/faraday.rb +1 -1
  59. data/lib/graph_weaver/transport/http.rb +29 -12
  60. data/lib/graph_weaver/transport.rb +11 -34
  61. data/lib/graph_weaver/version.rb +1 -1
  62. data/lib/graph_weaver.rb +203 -119
  63. metadata +10 -5
  64. data/lib/graph_weaver/schemas.rb +0 -48
  65. data/lib/graph_weaver/selection.rb +0 -120
  66. data/lib/graph_weaver/testing/values.rb +0 -98
data/README.md CHANGED
@@ -42,14 +42,47 @@ result.person&.nmae
42
42
  isn't defensive, it's the schema talking. A field you misspelled, or never
43
43
  selected, is a typecheck error rather than a `NoMethodError` in production.
44
44
 
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.
45
+ ## Start here
46
+
47
+ ```ruby
48
+ # Gemfile
49
+ gem "graph_weaver"
50
+ ```
51
+
52
+ In Rails, setup is one command:
53
+
54
+ ```sh
55
+ rails g graph_weaver:install https://api.example.com/graphql
56
+ ```
57
+
58
+ which writes the initializer, the `app/graphql` layout, the editor config and the
59
+ schema dump. **[Getting started](docs/getting_started.md)** walks the production
60
+ setup end to end.
61
+
62
+ You then rarely type `rake graph_weaver:generate` again: while the dev server is
63
+ up, a `.graphql` edit regenerates and reloads before the next request, the way a
64
+ route change does. Run it when you're ready to commit the Ruby.
65
+
66
+ Or skip the build step and poke at an API from a console —
67
+ anything holding a schema parses, and the module runs on what parsed it:
68
+
69
+ ```ruby
70
+ api = GraphWeaver.new("https://countries.trevorblades.com/")
71
+ CountryQuery = api.parse("queries/country.graphql") # a path or a raw string
72
+ CountryQuery.execute!(code: "JP").country&.capital # => "Tokyo"
73
+
74
+ api.run!("query { continents { name } }").continents # or no module at all
75
+ ```
76
+
77
+ The **[examples](examples/)** run that path for real, smallest first: a public API
78
+ in 30 lines, a paginated search, the production path against GitHub, and the
79
+ federated graph below.
47
80
 
48
81
  ## Precise types are expensive to fake, so it fakes them for you
49
82
 
50
83
  Generation makes result types exact, which makes them tedious to build by hand —
51
84
  and most generators stop there and leave you the fixtures. GraphWeaver ships the
52
- fabricator. One line in the spec helper:
85
+ fakes. One line in the spec helper:
53
86
 
54
87
  ```ruby
55
88
  require "graph_weaver/rspec"
@@ -68,77 +101,64 @@ end
68
101
  ```
69
102
 
70
103
  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).
104
+ `--seed 4242` hands back that same person and a failure reproduces.
105
+
106
+ Random data answers "does this render". When the example is *about* the data, pin
107
+ the fields it's about and let the rest stay fabricated:
108
+
109
+ ```ruby
110
+ graphql_fake("Person.name" => "Ada", "Person.pets" => [{ "name" => "Shelby" }, {}])
111
+
112
+ person.name # => "Ada"
113
+ person.pets.first.name # => "Shelby" — the second pet is still fabricated
114
+ person.pets.size # => 2 — a pinned list is as long as you write it
115
+ ```
116
+
117
+ Keys are schema names — a field, or a whole type: `"Person" => build(:person)`
118
+ reads the selected fields off your factory's object and fabricates the rest. They
119
+ are checked and spellchecked, so a typo raises instead of leaving the example
120
+ green against random data. The tag also picks a *real* client
121
+ when you want one: `:in_process` runs your resolvers, `:router` runs them across a
122
+ federated graph. Field-level failure simulation and record/replay cassettes with
123
+ anonymization are in [testing](docs/testing.md).
75
124
 
76
125
  ## Federation without a gateway
77
126
 
78
127
  When your app is both a GraphQL client and a subgraph, the local router plans a
79
128
  query across the composed supergraph and runs your **real resolvers** over the
80
129
  boundary — no gateway process, no node, no sockets. That's
81
- [`examples/federation.rb`](examples/federation.rb), the example that needs no network:
130
+ [`examples/federation.rb`](examples/federation.rb), the example that needs no
131
+ network. Part of what it prints:
82
132
 
83
133
  ```
84
- $ bundle exec examples/federation.rb
85
- #<GraphWeaver::Testing::Router subgraphs=["accounts", "products", "reviews"]>
86
-
87
- dpep reviewed 2 products:
88
- Table ($899) — Love it
89
- Couch ($1299) — Too expensive
90
-
91
134
  fetches:
92
135
  → accounts root fields
93
136
  → reviews _entities × 1 User
94
137
  → products _entities × 2 Product
95
138
  ```
96
139
 
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 72 queries identical, 2 refused, 0 wrong
140
+ The trace is the query plan: every node at a level goes in one `_entities` call,
141
+ so two products cost one fetch. Anything it can't answer *faithfully* it refuses
142
+ at plan time rather than guessing — the example prints one of those too, naming
143
+ the coordinate that stopped it and what to rename. And it's diffed against a real
144
+ `@apollo/gateway` over the same supergraph:
145
+ currently 73 queries identical, 2 refused, 0 wrong
101
146
  ([`spec/integration/router_parity_spec.rb`](spec/integration/router_parity_spec.rb)).
102
147
  See [federation](docs/federation.md).
103
148
 
104
149
  ## The schema keeps itself honest
105
150
 
106
151
  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).
112
-
113
- ## Start here
114
-
115
- ```ruby
116
- # Gemfile
117
- gem "graph_weaver"
118
- ```
119
-
120
- In Rails, setup is then one command:
121
-
122
- ```sh
123
- rails g graph_weaver:install https://api.example.com/graphql
124
- ```
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:
152
+ `schema:refresh` re-introspects the committed dump, `schema:diff` names what
153
+ changed when the server has drifted, `queries:check` names the queries that
154
+ drift broke and where, and `verify` fails when the checked-in Ruby is stale.
155
+ Generation is deterministic — same schema and queries, byte-identical files — so
156
+ regenerating never shows a diff you didn't earn. See
157
+ [getting started](docs/getting_started.md#5-verify-in-ci).
130
158
 
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.
159
+ **Any release can change what codegen emits**, patch releases included — fixing a
160
+ generated type is a byte change. So `rake graph_weaver:generate` is part of every
161
+ upgrade, and `verify` is what tells you when you've skipped it.
142
162
 
143
163
  #### Also in the box
144
164
 
@@ -155,11 +175,12 @@ federated graph above.
155
175
  - **[Federation](docs/federation.md)** — supergraph vs API schema, the local router, what it refuses
156
176
  - **[Transports](docs/transports.md)** — the execute contract, Faraday, retries and backoff
157
177
  - **[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
178
+ - **[Custom scalars](docs/scalars.md)** — what's registered already, codec inference, input coercion
159
179
  - **[Cassettes](docs/cassettes.md)** — capture and replay real responses, anonymized
160
180
  - **[Editor support](docs/editors.md)** — five lines of YAML for schema autocomplete in `.graphql` files, no JS project
161
181
  - **[Against a real API](docs/real_world.md)** — introspecting a live endpoint, GitHub end to end
162
182
  - **[Logging](docs/logging.md)** — point `GraphWeaver.logger` at any Logger
183
+ - **[Upgrading](docs/upgrading.md)** — regenerate on every bump, and what 0.5.0 moved
163
184
 
164
185
  ----
165
186
  ## Development
data/docs/cassettes.md CHANGED
@@ -113,7 +113,12 @@ rake graph_weaver:cassettes:anonymize # every cassette in cassette_dir, in pla
113
113
  ```
114
114
 
115
115
  Anonymization preserves shape, so an anonymized cassette still passes
116
- `cassettes:check`.
116
+ `cassettes:check` — including a custom scalar, whose replacement is the same
117
+ one [`FakeClient`](testing.md#fabricated-data--graphql-fake) would fabricate.
118
+ A scalar registered as *your own* class needs a pin for the type in
119
+ `Testing.config.overrides` (`{ "Money" => "12.00" }` — [pins](testing.md#pins)),
120
+ which the anonymizer reads too; without one, anonymizing refuses rather than
121
+ writing a value the codec can't read back.
117
122
 
118
123
  ## Cassette or FakeClient?
119
124
 
data/docs/editors.md CHANGED
@@ -22,7 +22,9 @@ That's the whole setup. The paths are graph_weaver's conventions
22
22
  (`GraphWeaver.schema_path`, `queries_paths`, `fragments_paths`) — if you moved
23
23
  them, move these to match. Include the fragments directory: an editor
24
24
  validating a query that spreads a shared fragment reports `Unknown fragment`
25
- unless the fragment files are in `documents` too.
25
+ unless the fragment files are in `documents` too. The generator writes that
26
+ line whether or not you have fragments yet — the directory it names doesn't
27
+ exist until you add one, and a glob matching nothing is fine.
26
28
 
27
29
  An SDL dump works just as well if you took one (`cache: :graphql`):
28
30
 
data/docs/errors.md CHANGED
@@ -1,8 +1,18 @@
1
1
  # Errors
2
2
 
3
- `execute` returns a typed **`Response` envelope** rather than raising on GraphQL
4
- errors so partial data and top-level `extensions` (cost, throttle) survive.
5
- `execute!` is the shortcut when you just want the result:
3
+ What comes back when something goes wrong, and what to rescue. Read it when you
4
+ write the first `rescue` around a query, or when you need to tell "the network
5
+ broke" apart from "the server said no" apart from "the response didn't fit".
6
+
7
+ Two names travel together here, and they're close enough to trip on:
8
+
9
+ - **`Result`** — the struct holding *this* query's data, e.g. `PersonQuery::Result`.
10
+ - **`Response`** — the **envelope** around it, `GraphWeaver::Response[Result]`,
11
+ carrying `data`, `errors` and `extensions`.
12
+
13
+ `execute` returns the envelope rather than raising on GraphQL errors, so partial
14
+ data and top-level `extensions` (cost, throttle) survive. `execute!` is the
15
+ shortcut to the result:
6
16
 
7
17
  ```ruby
8
18
  PersonQuery.execute!(id: "1") # => Result, or raises QueryError (== execute(...).data!)
@@ -30,7 +40,7 @@ subclass says where it failed:
30
40
 
31
41
  | Class | When |
32
42
  |-------|------|
33
- | `TransportError` | never reached the server — DNS, connection refused, TLS, timeout |
43
+ | `TransportError` | no response came back — DNS, connection refused, TLS, timeout, a socket that died mid-body |
34
44
  | `ServerError` | reached it, non-2xx HTTP — `#status`, `#body`, `#headers`, `#retry_after`, `#throttled?` |
35
45
  | `QueryError` | 200 body with top-level GraphQL errors — `#errors`, `#data`, `#extensions`, `#codes`, `#throttled?` |
36
46
  | `TypeError` | the response wouldn't cast into the generated structs — `#struct`, `#cause` |
@@ -70,17 +80,34 @@ Or skip the hand-rolling: [`Retry`](transports.md#retries) wraps any client and
70
80
  already defaults to exactly the policy above — transport failures always,
71
81
  `ServerError` on 5xx plus 408/429, and GraphQL error codes you name.
72
82
 
73
- **Top-level scalar variables** fail like any Ruby method call, *outside* the
74
- hierarchy on purpose — passing the wrong Ruby type for a scalar kwarg is a
75
- programming bug, not caller input: a wrong-typed one raises sorbet-runtime's
76
- `TypeError` ("Parameter 'page': Expected type T.nilable(Integer), got type
77
- String"), a missing required one a plain `ArgumentError` ("missing keyword: :id").
83
+ **A status with an obvious next step says it.** A 3xx appends "redirects are
84
+ not followed" and the `Location` to repoint the client at replaying a POST,
85
+ with its `Authorization` header, at a host the server named isn't the
86
+ library's call. A 401 or 403 appends "check `auth:` the token, and its
87
+ scopes".
88
+
89
+ **Everything you pass to `execute` is caller input**, so a value that won't
90
+ convert raises `GraphWeaver::InputError` — top-level scalar variables included.
91
+ They name the variable and the operation, since the value alone locates nothing
92
+ in an app that runs a hundred queries:
93
+
94
+ ```
95
+ $count of Compute: expected an Int, got "lots"
96
+ ```
97
+
98
+ The value is usually the whole diagnosis, so it is quoted — unless the key it
99
+ arrived under is one your `filter_parameters` covers, in which case the message
100
+ reads `$password of Login: [FILTERED]`. Error messages reach the log at `warn`,
101
+ above the level that gates the variables line, so they are scrubbed by the same
102
+ list ([logging](logging.md#filtered-variables)).
103
+
104
+ A *missing* required kwarg is still a plain `ArgumentError` ("missing keyword:
105
+ :id") — that's Ruby's, and it is a programming bug rather than bad input.
78
106
 
79
- **Input-object variables** are the caller-input case, so they're *inside* the
80
- hierarchy. When you pass an input object as a hash (or struct) it's built
81
- through the generated `coerce`, and anything wrong in there raises
82
- `GraphWeaver::InputError` — one rescue point for turning invalid input into a
83
- 422:
107
+ **What's inside an input object** reports the same way. Pass one as a hash (or
108
+ struct) and it's built through the generated `coerce`, and anything wrong in
109
+ there raises `GraphWeaver::InputError` too so one rescue point turns invalid
110
+ input into a 422:
84
111
 
85
112
  ```ruby
86
113
  rescue GraphWeaver::InputError => e
@@ -92,7 +119,16 @@ end
92
119
  ```
93
120
 
94
121
  A nested filter reports the innermost input type, so the error points at the
95
- input that actually held the bad field.
122
+ input that actually held the bad field. Passing something that is neither — a
123
+ bare `String` where the input goes — reports the same way. A call site that
124
+ *spells* the wrong type is caught earlier and better, by `srb tc`: the sig is
125
+ as narrow as the schema, and only untyped values reach the runtime check
126
+ ([why](generated_modules.md#variables-become-typed-kwargs)).
127
+
128
+ `#struct` is the generated input struct *class* where generation produced one,
129
+ and the GraphQL type *name* where it didn't — a federation representation
130
+ builds a plain Hash, so an entity has only its name to give. `to_h`'s
131
+ `"struct"` is the name either way, so branch on that.
96
132
 
97
133
  Business/validation failures returned *as data* (Shopify-style `userErrors { field
98
134
  message code }`) aren't errors here — they're just fields you selected, so they
@@ -139,6 +175,14 @@ response.report
139
175
  `GraphQLError#field` strips list indices (`people.3.email` → `people.email`) —
140
176
  the stable grouping key; the raw `#path` keeps indices for exact location.
141
177
 
178
+ `Response#to_h` decomposes the envelope the same way: `{"data" =>, "errors" =>,
179
+ "extensions" =>}`, with each error as its JSON-ready hash. `data` stays the
180
+ typed struct — it is deliberately not re-serialized, because `T::Struct#serialize`
181
+ would give snake_case keys where the wire is camelCase, drop null fields, and
182
+ leave a registered scalar as the Ruby object its codec built. That output would
183
+ look like the server's response without being one, so serialize the typed data
184
+ yourself when you need to re-emit it.
185
+
142
186
  ## Stale schemas
143
187
 
144
188
  GraphQL has no schema-version signal, so a schema change surfaces as the
@@ -153,6 +197,19 @@ refresh the schema cache.
153
197
  When wire data disagrees with the types the schema promised at generation time
154
198
  (a nil where non-null was declared, a malformed scalar, an unknown enum value),
155
199
  casting raises `GraphWeaver::TypeError` naming the failing generated struct,
156
- with the original exception as `#cause`. Simulate one in tests with
200
+ with the original exception as `#cause`.
201
+
202
+ A cast's own complaint is about the value and nothing else — "invalid date"
203
+ locates nothing on a struct holding four of them — so a casting leaf also
204
+ carries **its response key**, and three failures that keep happening say whose
205
+ bug it is rather than leaving you sorbet's words:
206
+
207
+ | what came back | what the message adds |
208
+ |---|---|
209
+ | 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")`) |
210
+ | 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 |
211
+ | a field the server nulled **with a reason** | the server's own explanation, rather than only sorbet's nil complaint |
212
+
213
+ Simulate one in tests with
157
214
  `GraphWeaver::Testing::FakeClient.new(schema:, corrupt: "Person.birthday")` — see
158
215
  [testing](testing.md).