graph_weaver 0.7.5 → 0.7.7

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +43 -43
  3. data/docs/errors.md +7 -3
  4. data/docs/generated_modules.md +66 -16
  5. data/docs/getting_started.md +39 -19
  6. data/docs/logging.md +79 -35
  7. data/docs/migrating.md +11 -4
  8. data/docs/scalars.md +12 -3
  9. data/docs/testing.md +75 -13
  10. data/docs/upgrading.md +43 -3
  11. data/examples/README.md +4 -2
  12. data/examples/github/generate.rb +22 -8
  13. data/examples/github/generated/star_mutation.rb +2 -2
  14. data/examples/github/generated/stargazers_query.rb +2 -2
  15. data/examples/github/generated/starred_query.rb +2 -2
  16. data/examples/github/run.rb +1 -0
  17. data/examples/github/setup.rb +16 -8
  18. data/graph_weaver.gemspec +15 -6
  19. data/lib/generators/graph_weaver/install_generator.rb +20 -2
  20. data/lib/graph_weaver/client.rb +0 -23
  21. data/lib/graph_weaver/codegen/aliases.rb +15 -3
  22. data/lib/graph_weaver/codegen/emit.rb +7 -6
  23. data/lib/graph_weaver/codegen/enum_type.rb +26 -2
  24. data/lib/graph_weaver/codegen/nodes.rb +21 -2
  25. data/lib/graph_weaver/codegen.rb +106 -54
  26. data/lib/graph_weaver/graph.rb +67 -6
  27. data/lib/graph_weaver/in_process.rb +1 -1
  28. data/lib/graph_weaver/input_struct.rb +29 -4
  29. data/lib/graph_weaver/internal/overrides.rb +126 -14
  30. data/lib/graph_weaver/internal/test_clients.rb +29 -7
  31. data/lib/graph_weaver/internal/unused.rb +30 -11
  32. data/lib/graph_weaver/internal/values.rb +17 -5
  33. data/lib/graph_weaver/internal.rb +23 -6
  34. data/lib/graph_weaver/log_subscriber.rb +27 -17
  35. data/lib/graph_weaver/logging.rb +89 -53
  36. data/lib/graph_weaver/parsing.rb +32 -3
  37. data/lib/graph_weaver/query_module.rb +48 -8
  38. data/lib/graph_weaver/railtie.rb +250 -11
  39. data/lib/graph_weaver/rspec.rb +41 -17
  40. data/lib/graph_weaver/schema_diff.rb +24 -5
  41. data/lib/graph_weaver/schema_loader.rb +22 -9
  42. data/lib/graph_weaver/tasks.rb +71 -44
  43. data/lib/graph_weaver/testing/fake_client.rb +24 -21
  44. data/lib/graph_weaver/testing.rb +27 -7
  45. data/lib/graph_weaver/transport.rb +1 -1
  46. data/lib/graph_weaver/version.rb +1 -1
  47. data/lib/graph_weaver.rb +76 -43
  48. metadata +2 -2
data/docs/logging.md CHANGED
@@ -13,8 +13,8 @@ Pick the level, get the story:
13
13
 
14
14
  | Level | What you see |
15
15
  |-------|--------------|
16
- | `debug` | the wire: query + variables per call (long queries truncated), response status/bytes/content type, request timing, connection open/drop, dynamically parsed modules |
17
- | `info` | one line per operation in Rails (see [Instrumentation](#instrumentation)), schema introspection (with timing) and cache hits/misses, the transport a client built, generated files written and any unregistered scalars, query modules loaded, a retry's wait and attempt number — and in development, what's being watched and what a save regenerated |
16
+ | `debug` | the wire: query + variables per call (long queries truncated), response status/bytes/content type, request timing, one line per attempt (its url status and which retry it was), connection open/drop, dynamically parsed modules |
17
+ | `info` | one line per call in Rails (see [Instrumentation](#instrumentation)), schema introspection (with timing) and cache hits/misses, the transport a client built, generated files written and any unregistered scalars, query modules loaded, a retry's wait and attempt number — and in development, what's being watched and what a save regenerated |
18
18
  | `warn` | every GraphWeaver error raised — `TransportError`, `ServerError`, `QueryError`, `QueryValidationError`, `CastError` — registrations the schema being generated against can't match, a retry skipped because the operation was a mutation, and every fetch the test router answered with fabricated data |
19
19
  | `error` | development only: a `.graphql` edit that won't compile, with its file and position — the modules already loaded keep serving |
20
20
 
@@ -61,9 +61,10 @@ value, since `expected an Int, got "lots"` is the whole diagnosis.
61
61
  ## Instrumentation
62
62
 
63
63
  A logger tells a human what happened; an APM needs to time it and count it.
64
- `GraphWeaver.instrumenter` is one callable wrapping every request — over the wire
65
- *and* in-process. **In Rails you set nothing**: the railtie installs the adapter
66
- below and attaches `GraphWeaver::LogSubscriber` on top of it. Everywhere else:
64
+ `GraphWeaver.instrumenter` is one callable wrapping every call GraphWeaver
65
+ makes — over the wire *and* in-process. **In Rails you set nothing**: the
66
+ railtie installs the adapter below and attaches `GraphWeaver::LogSubscriber` on
67
+ top of it. Everywhere else:
67
68
 
68
69
  ```ruby
69
70
  GraphWeaver.instrumenter = lambda do |event, payload, &block|
@@ -76,40 +77,74 @@ An instrumenter you set yourself is never replaced — including
76
77
  and return its value; a failure propagates through it, so the hook sees the
77
78
  exception and can record it.
78
79
 
79
- The one event is `GraphWeaver::EXECUTE_EVENT` (`"execute.graph_weaver"`) — one
80
- request, start to parsed response, whichever client slot served it:
80
+ There are two events, and the second happens inside the first:
81
+
82
+ | Event | One of these is |
83
+ |-------|-----------------|
84
+ | `GraphWeaver::OPERATION_EVENT` (`"operation.graph_weaver"`) | one call of a generated module's `execute`/`execute!`, start to typed result or raise — the request it made, every retry and backoff beneath it, and the cast into your structs |
85
+ | `GraphWeaver::EXECUTE_EVENT` (`"execute.graph_weaver"`) | one request, start to parsed response, whichever client slot served it — so a call a [`Retry`](transports.md#retries) took three goes at is one operation event and three of these |
86
+
87
+ **Alert on the operation event**: it is the one that says what the *caller*
88
+ got. A `CastError` is raised after the response is back, so the request has
89
+ already closed `:ok` by the time the app sees a failure. It also fires at the
90
+ module seam, which every client slot passes through — a [test mode's](testing.md)
91
+ fake, the router and a cassette all report here, so a spec can assert on
92
+ instrumentation under any `graphql:` tag.
81
93
 
82
94
  ```ruby
83
- ActiveSupport::Notifications.subscribe(GraphWeaver::EXECUTE_EVENT) do |*, payload|
95
+ ActiveSupport::Notifications.subscribe(GraphWeaver::OPERATION_EVENT) do |*, payload|
84
96
  StatsD.timing("graphql.#{payload[:operation] || "anonymous"}", payload[:duration_ms],
85
97
  tags: ["status:#{payload[:status]}", "kind:#{payload[:kind]}", "code:#{payload[:code]}"])
86
98
  end
87
99
  ```
88
100
 
89
- **A subscriber that raises takes the request down with it** — the response was
101
+ Two calls don't produce one. `from_response` on its own reports nothing — no
102
+ call was made. And a module generated by 0.7.5 or earlier shows this seam
103
+ only half of one, so it emits the request event alone until you regenerate:
104
+ half a call reported `:ok` is the very thing the event exists to stop.
105
+
106
+ **A subscriber that raises takes the call down with it** — the response was
90
107
  computed and is then thrown away. That is `ActiveSupport::Notifications`' own
91
108
  semantics, the same on `sql.active_record`, so rescue inside the block. A
92
109
  `GraphWeaver::LogSubscriber` subclass already does.
93
110
 
94
- ### The payload
111
+ ### The operation payload
95
112
 
96
113
  | Key | When | |
97
114
  |-----|------|--|
98
115
  | `:operation` | always | the operation name sent with the request, nil for an anonymous document — what a trace keys on (a generated module always has one) |
99
- | `:client` | always | the class that ran it: `GraphWeaver::Transport::HTTP`, `GraphWeaver::InProcess`, your own |
116
+ | `:module` | always | the generated module's name, as a String — `nil` for one with no name (a [`GraphWeaver.parse`](getting_started.md)). The fact this seam has and the request doesn't: two graphs can name the same operation, and a trace that is slow wants the file |
117
+ | `:graph` | always | the [graph](getting_started.md#more-than-one-schema) the module was declared under, as a Symbol — `nil` for a module that names none. Never inferred from the client |
100
118
  | `:kind` | always | `:query`, `:mutation` or `:subscription` — what the document runs, so a write failure rate is a payload question rather than a guess at the operation's name. The shorthand `{ ... }` document is a `:query`. The same reading decides whether [`Retry`](transports.md#retries) may repeat the request |
119
+ | `:client` | always | the client the module **resolved to**: a test mode's stand-in names itself, a graph's client names itself, and over the wire it is `GraphWeaver::Client` — the request event beneath names the transport that carried it |
120
+ | `:status` | always | `:ok` (a typed result), `:errors` (the response carried GraphQL errors — what `execute!` raises `QueryError` for), or `:failed` (the call raised: the transport, a `CastError`, anything) |
121
+ | `:duration_ms` | always | the caller's wall clock — dispatch, every retry *and its backoff*, and the cast |
122
+ | `:code` | on `:errors` | the machine-readable reason, as on the request event |
123
+ | `:error` | on `:failed` | the exception's class name |
124
+
125
+ Not on it: `:url`, `:http_status`, `:retries`. Those describe one attempt, and
126
+ a call that took three goes has no single answer for any of them — read them
127
+ off the request events nested inside.
128
+
129
+ ### The request payload
130
+
131
+ | Key | When | |
132
+ |-----|------|--|
133
+ | `:operation` | always | the operation name sent with the request, nil for an anonymous document |
134
+ | `:client` | always | the class that ran it: `GraphWeaver::Transport::HTTP`, `GraphWeaver::InProcess`, your own |
135
+ | `:kind` | always | `:query`, `:mutation` or `:subscription`, read off the document |
101
136
  | `:status` | always | `:ok`, `:errors` (a response carrying GraphQL errors), or `:failed` (it raised) |
102
- | `:duration_ms` | always | start to parsed response, for **this attempt** — under a [`Retry`](transports.md#retries) no event covers the backoff sleep, so a tracer recovers the caller's wall clock from the sibling spans instead: the first attempt's start to the last one's end |
137
+ | `:duration_ms` | always | start to parsed response, for **this attempt** — the backoff between attempts belongs to the operation event above, which is where the caller's wall clock lives |
103
138
  | `:url` | over the wire | the endpoint; nil in-process |
104
139
  | `:http_status` | over the wire | what the server answered with, success or not; nil in-process |
105
140
  | `:schema` | in-process | the schema class's name, as a String, so a payload logs as it stands |
106
141
  | `:code` | on `:errors` | the machine-readable reason, always a String or `nil` — the first `extensions.code` *any* of the errors carries, not the first error's, since a code that exists beats the absence of one at position 0. Present and `nil` when the errors carry none. Never an HTTP status: that is `:http_status` |
107
142
  | `:error` | on `:failed` | the exception's class name |
108
143
  | `:retries` | under a `Retry` | how many retries this attempt follows. Each attempt is its own event, so one retried call is three events reading 0, 1, 2 — present at 0 rather than absent, so its absence means nothing was retrying |
109
- | `:graph` | always | the [graph](getting_started.md#more-than-one-schema) the generated module was declared under, as a Symbol — `nil` for a module that names none, and for a client called directly. Never inferred from the client: a wrong graph on a request is worse than no graph |
144
+ | `:graph` | always | the same label the operation above it carries, and `nil` for a request no generated module made — a client called directly. Never inferred from the client: a wrong graph on a request is worse than no graph |
110
145
 
111
- Every key is filled in before your callable's block returns, so a subscriber reads
112
- a complete payload; `ActiveSupport::Notifications` adds `:exception` and
146
+ Every key is filled in before your callable's block returns, so a subscriber
147
+ reads a complete payload; `ActiveSupport::Notifications` adds `:exception` and
113
148
  `:exception_object` of its own when the block raises.
114
149
 
115
150
  **`:graph` labels one request**, never what a *server* does while answering one:
@@ -117,33 +152,35 @@ an in-process resolver that calls out produces an event of its own. The label is
117
152
  fiber-local, so a dispatch that crosses a `Fiber` — graphql-ruby's `Dataloader`
118
153
  does — arrives with `:graph` unset. No label rather than a wrong one.
119
154
 
120
- **Never the query text or the variables**: the payload fans out to subscribers
121
- that know none of the filtering rules, so the rule here isn't "scrub it", it's
122
- that it was never there. `:url` is the one thing on the payload that *is*
123
- scrubbed, because a url can itself be a credential — and the default names apply
124
- to its query parameters even when you have emptied or narrowed
125
- `filter_parameters`. Your list widens that; it can't narrow it.
155
+ **Never the query text or the variables**, on either event: the payload fans out
156
+ to subscribers that know none of the filtering rules, so the rule here isn't
157
+ "scrub it", it's that it was never there. `:url` is the one thing on either
158
+ payload that *is* scrubbed, because a url can itself be a credential — and the
159
+ default names apply to its query parameters even when you have emptied or
160
+ narrowed `filter_parameters`. Your list widens that; it can't narrow it.
126
161
 
127
162
  ### One line per operation
128
163
 
129
- In Rails the railtie also attaches `GraphWeaver::LogSubscriber`, which turns each
130
- event into one line — the shape ActiveRecord uses for a query:
164
+ In Rails the railtie also attaches `GraphWeaver::LogSubscriber`, which turns
165
+ each operation event into one line — the shape ActiveRecord uses for a query:
131
166
 
132
167
  ```
133
168
  GraphWeaver PersonQuery (12.3ms) ok
134
169
  GraphWeaver PersonQuery (8.1ms) errors [THROTTLED]
135
170
  GraphWeaver PersonQuery (31.2ms) failed GraphWeaver::TransportError
136
- GraphWeaver PersonQuery (5.0ms) ok (retry 2)
171
+ GraphWeaver PersonQuery (44.0ms) failed GraphWeaver::CastError
137
172
  GraphWeaver billing/InvoicesQuery (12.3ms) ok
138
173
  ```
139
174
 
140
- The operation is prefixed by its graph when the request carried one, so an app
175
+ The operation is prefixed by its graph when the call carried one, so an app
141
176
  with several graphs sorts its own log and an app with one never sees the prefix.
142
177
 
143
- **The summary is info, the wire is debug.** This is the only GraphWeaver line at
144
- info, so a production log gets one per operation and nothing that can carry PII;
145
- debug adds the query, the variables and the response *beneath* it. It writes
146
- through `GraphWeaver.logger`.
178
+ **The operation is info, the attempt is debug.** The info line is one *call*, so
179
+ it says what the caller got and a production log gets one per call — a retried
180
+ call included, whose attempts and their backoff are inside that one duration.
181
+ Debug adds a line per attempt in the same shape, carrying the url's status and
182
+ which try it was, beneath the query and the variables. It writes through
183
+ `GraphWeaver.logger`.
147
184
 
148
185
  Outside Rails the same line is an instrumenter of your own, writing
149
186
  `payload[:operation]`, `[:duration_ms]` and `[:status]` from an `ensure` — the
@@ -151,11 +188,15 @@ shape the two tracing examples below use.
151
188
 
152
189
  ### OpenTelemetry
153
190
 
191
+ A span per operation, with a span per attempt inside it:
192
+
154
193
  ```ruby
155
194
  tracer = OpenTelemetry.tracer_provider.tracer("graph_weaver")
156
195
 
157
- GraphWeaver.instrumenter = lambda do |_event, payload, &block|
158
- tracer.in_span("graphql #{payload[:operation] || "query"}") do |span|
196
+ GraphWeaver.instrumenter = lambda do |event, payload, &block|
197
+ name = event == GraphWeaver::OPERATION_EVENT ? "graphql #{payload[:operation] || "query"}" : "graphql request"
198
+
199
+ tracer.in_span(name) do |span|
159
200
  block.call
160
201
  ensure
161
202
  span.add_attributes(payload.compact.transform_keys { "graphql.#{_1}" }.transform_values(&:to_s))
@@ -171,7 +212,8 @@ span status itself for a *raise* only, which is the last line's reason to exist.
171
212
  **Span status is not the alerting signal; `payload[:status]` is.** A response
172
213
  carrying GraphQL errors is a 200 that returned normally, so nothing raises and a
173
214
  span left to itself is `UNSET` — an SLO built on span status alone misses every
174
- GraphQL-level failure there is. Alert on `:status` and group by `:code`.
215
+ GraphQL-level failure there is. Alert on the operation event's `:status` and
216
+ group by `:code`.
175
217
 
176
218
  **Propagating the trace outward** is a header, and a
177
219
  [header value may be a callable](transports.md#headers) resolved per request —
@@ -191,9 +233,11 @@ GraphWeaver.instrumenter = lambda do |event, payload, &block|
191
233
  end
192
234
  ```
193
235
 
194
- Datadog's Net::HTTP and Faraday contribs already trace the transport layer, so
195
- with them on you have a span for the POST. This adds the span *above* it, named
196
- for the operation — the one that means anything, since every GraphQL call is a
236
+ One hook covers both events, and the event name is the span name — so an
237
+ operation and its attempts nest under distinct resources. Datadog's Net::HTTP
238
+ and Faraday contribs already trace the transport layer, so with them on you have
239
+ a span for the POST as well. This adds the spans *above* it, and the one named
240
+ for the operation is the one that means anything, since every GraphQL call is a
197
241
  POST to the same url.
198
242
 
199
243
  ## Details
data/docs/migrating.md CHANGED
@@ -27,10 +27,17 @@ two side by side is the right shape anyway.
27
27
  `queries:check` have no server to ask. `rake graph_weaver:schema:refresh`
28
28
  rewrites it from the client the app points at (or `URL=<your endpoint>` once)
29
29
  and records the provenance. Do it now: until you do, `queries:check` is
30
- re-reading the file it is meant to be checking against, and says so.
31
- 3. **Register scalars and enums, and regenerate**, before porting any code.
32
- Registrations are baked into generated source, so one added later reaches
33
- nothing until the next `rake graph_weaver:generate` ([scalars](scalars.md)).
30
+ re-reading the file it is meant to be checking against, and says so. It
31
+ rewrites the file **in place, in graph_weaver's format** — pretty-printed
32
+ introspection JSON with a `graph_weaver:` provenance key beside `data`.
33
+ graphql-client goes on reading it, since `load_schema` hands the parsed hash
34
+ to graphql-ruby and graphql-ruby takes `data` and ignores its siblings; a dump
35
+ anything *else* reads is worth checking once.
36
+ 3. **Register scalars and enums, and regenerate** — if the API declares any.
37
+ Plenty don't: one whose leaves are all `String`, `Int`, `ID` and `Boolean` has
38
+ nothing to register and skips this step whole. Registrations are baked into
39
+ generated source, so one added later reaches nothing until the next
40
+ `rake graph_weaver:generate` ([scalars](scalars.md)).
34
41
  4. **Port one query end to end** — write the `.graphql` file, generate, rewrite
35
42
  its call site — and leave the specs alone. They pass untouched. That single
36
43
  commit is the proof for every one after it.
data/docs/scalars.md CHANGED
@@ -433,7 +433,12 @@ It raises naming every scalar that disagreed and which way:
433
433
 
434
434
  The fabricated value is all it has to work with, so pin the one that matters:
435
435
  `config.overrides = { "Decimal" => "123456789.123456789" }` is how the precision
436
- case gets exercised at all — two decimal places always survive a Float. Pass the
436
+ case gets exercised at all — two decimal places always survive a Float. That is
437
+ the only door onto a pin here: `check_scalars!` takes the schema and nothing
438
+ else, and runs outside every fake, so `graphql_fake` would be a no-op. The last
439
+ leg compares with `==`, so a registered class that defines none is reported as
440
+ uncheckable rather than lossy — two identical spellings mean identity, not a
441
+ loss. Pass the
437
442
  schema **class**; a dump's scalars pass values through, so against one this checks
438
443
  only that a registration's `cast:` accepts what its own `serialize:` writes, which
439
444
  is a different question (see below).
@@ -501,7 +506,9 @@ Two safety properties do the real work:
501
506
  a member, or generation fails naming the gaps (`PetKind has no member for
502
507
  Species value(s) DOG — add them, pin with map:, or absorb with fallback:`), so
503
508
  your enum drifting from the server's is caught by `rake graph_weaver:generate`,
504
- not in production.
509
+ not in production. The other direction only *warns* — one app-wide `T::Enum` can
510
+ serve two APIs that each expose a subset — and a kwarg carrying a member this
511
+ schema declares no value for is refused by name at the call.
505
512
  - **`fallback:` for forward-compat**: `fallback: PetKind::Unknown` makes *casting*
506
513
  absorb wire values the server added after you generated, so responses keep
507
514
  flowing instead of raising. Inputs stay strict either way: a typo'd input is your
@@ -536,7 +543,9 @@ Three things follow from that, and they are the whole rule:
536
543
  singleton, so there is nowhere to put it; the cast writes one debug line
537
544
  (`GraphQLTypes::Species absorbed "AXOLOTL" into Other`) and that is the record
538
545
  — so a presenter humanising the server's spelling can't, and falls back to a
539
- label of its own.
546
+ label of its own. `as_json` writes the member's own wire value, `"__other__"`,
547
+ which `from_h` reads straight back — but `render json: result` hands that
548
+ sentinel to whoever is downstream, and no server declares it.
540
549
  - **Inputs stay strict.** No wire value means `Other`, so a variable carrying it
541
550
  is refused rather than sent. Everything else is unchanged: a typo'd input is
542
551
  your bug, not drift.
data/docs/testing.md CHANGED
@@ -136,6 +136,15 @@ leaving the example green against random data. **Schema vocabulary, not Ruby:**
136
136
  spelled the way *that* schema spells it, so a Hasura table type is
137
137
  `"pokemon_v2_pokemon"` and not a Ruby-cased guess at it.
138
138
 
139
+ **The key is checked; the value is yours.** `"Pet.species" => "RABBIT"` is
140
+ accepted on an enum declaring no `RABBIT`, and the fake serves it as written — a
141
+ pin is the escape hatch, so nothing second-guesses what you put through it. On an
142
+ enum registered
143
+ [`fallback: true`](scalars.md#values-the-server-hasnt-told-you-about-yet) that is
144
+ how you rehearse drift, since the value lands in the catch-all member exactly as
145
+ a real server's new one would; without a fallback the cast raises where a real
146
+ response would.
147
+
139
148
  A pin **merges**, and pins a **subtree** as readily as a leaf: name the fields
140
149
  the example is about and everything else in the selection is still fabricated. A
141
150
  pinned list is exactly as long as you write it — `{}` means "another one, all
@@ -158,7 +167,8 @@ snake_cased reader, puts the value on the wire the way its
158
167
  a `T::Enum` as its value), recurses into nested objects and arrays of them, and
159
168
  **fabricates any field the object doesn't answer**. Readers are field names, not
160
169
  aliases; `__typename` comes from the key, so at a union or interface pin the
161
- concrete type (`"Person"`, never `"Named"`). A FactoryBot sequence advances on
170
+ concrete type — `"Person"` or `"Person.name"`, never `"Named"` or
171
+ `"Named.name"`, and both spellings of the abstract one are refused. A FactoryBot sequence advances on
162
172
  its own counter, which is the one thing `--seed` can't reproduce.
163
173
 
164
174
  A **scalar type pin** is the one thing a scalar registered as *your own class*
@@ -217,9 +227,12 @@ end
217
227
  ```
218
228
 
219
229
  The live schema *class* is found for you (a schema dump has no resolvers, so it
220
- won't do). If two loaded classes match, or none does, it says so and asks for
221
- `config.schema = MySchema` — and in Rails, remember that an autoloaded schema
222
- isn't loaded until something references it.
230
+ won't do), from the three places one can be named: `config.schema` when you set
231
+ it to a class, else the class [that graph](getting_started.md#more-than-one-schema)
232
+ names, else the class your own `GraphWeaver.client` already runs in-process.
233
+ Nothing is *scanned* for: with none of the three it refuses, and names the fix
234
+ that fits — declare the class on the graph, write `graphql_in_process(MySchema)`
235
+ in the example, or set `config.schema = MySchema` for the suite.
223
236
 
224
237
  A federated app has no one live class, so the example says which subgraph it
225
238
  means: `graphql_in_process(Reviews::Schema)`. Testing one subgraph's resolvers
@@ -253,7 +266,11 @@ block to scope it for the example that needs two identities —
253
266
  `graphql_context(admin: true) { … }` — and call it with nothing to read the
254
267
  context back. Under `graphql: :fake` it refuses: there are no resolvers to
255
268
  receive a context, and silently ignoring one would leave an example asserting on
256
- data nothing scoped. Pin the data itself instead.
269
+ data nothing scoped. Pin the data itself instead. Under `graphql: :live` it
270
+ refuses too — including in an untagged example, which is `:live` by default, and
271
+ including when your own client is an `InProcess` running your resolvers: that
272
+ client carries the context it was *built* with, and `graphql_context` reaches
273
+ only the stand-ins the mode built for this example.
257
274
 
258
275
  ## A federated graph — `graphql: :router`
259
276
 
@@ -360,8 +377,17 @@ that is a pure *client* of someone else's API gets a schema-correct server
360
377
  without writing one. A graph with no schema at all is refused, **by `:wire`'s own
361
378
  name** — the one fallback the other tags have and this one can't use is your
362
379
  client's own schema, since reading it means introspecting the endpoint `:wire`
363
- has just stubbed. Commit a dump, or set `config.schema`. A graph whose `client`
364
- posts nowhere is refused by name too.
380
+ has just stubbed. Commit a dump, or set `config.schema`. That refusal waits until
381
+ one of *that graph's* modules runs, so a graph the example never touches never
382
+ refuses it.
383
+
384
+ **A graph whose `client` posts nowhere runs above the wire** — there is no
385
+ endpoint to stub, so it is served in the client slot, exactly as
386
+ `graphql: :in_process` would serve it. That is how an app that owns resolvers
387
+ *and* calls someone else's API tests the remote half over the wire: its
388
+ in-process graph runs in-process, and every graph posting to a url is still
389
+ served at that url. An example where *no* graph posts anywhere is refused — a
390
+ `:wire` that serves nothing tests no transport.
365
391
 
366
392
  **It says which, on the logger** — the choice is the one thing this tag makes for
367
393
  you, and it is invisible from inside the example. One line per endpoint, at
@@ -377,7 +403,12 @@ set `GraphWeaver::Testing.config.schema` — the case worth catching, because an
377
403
  app that owns real resolvers otherwise goes green against fabricated data with
378
404
  nothing said. A warning rather than a refusal, because a loaded class isn't proof
379
405
  you meant it *here* — a federated suite loads every subgraph's — and a fake
380
- behind the wire is a thing to want.
406
+ behind the wire is a thing to want. A graph that ran above the wire gets a line
407
+ of its own, since the transport the example asked for never ran for it:
408
+
409
+ ```
410
+ graph_weaver: :wire has no endpoint for graph :orders — its client posts to none, so its modules run above the wire, as graphql: :in_process would
411
+ ```
381
412
 
382
413
  **A helper says what goes behind the wire.** Under the other tags a `graphql_*`
383
414
  helper takes the client slot; under `:wire` it is served instead — the client
@@ -548,10 +579,14 @@ rather than guessing**:
548
579
  the wire value that graph's generated cast expects. (Pins and `overrides:` stay
549
580
  suite-wide, keyed by scalar name — one `"Money"` override for the run.)
550
581
  - **`:in_process`** needs the live schema *class*, since only that has resolvers:
551
- the one that graph names, else the one your client already runs in-process,
552
- else the loaded class that defines everything the schema declares — the same
553
- derive-verify-refuse rule that
554
- [maps subgraphs](federation.md#which-schema-serves-which-subgraph).
582
+ `config.schema` when you set it to a class, else the one that graph names, else
583
+ the one your client already runs in-process. Nothing is scanned for — a loaded
584
+ class is no evidence the app meant it *here* — so past those three
585
+ `:in_process` refuses, while [`:wire`](#over-the-wire--graphql-wire) reads the
586
+ same list and serves a fake, **warning** and naming the class it saw.
587
+ (Deriving a schema from what *is* loaded is the
588
+ [subgraph rule](federation.md#which-schema-serves-which-subgraph), where the
589
+ routing table says what a match has to define.)
555
590
  - **`:router`** plans against the composed supergraph **that graph** names, else
556
591
  `config.router = { supergraph: … }`, else the committed dump when *that*
557
592
  carries `@join__*` markers, else the dump your own client was built from
@@ -619,7 +654,17 @@ one ahead of yours, so a `before` setting `config.schema`, `config.router` or
619
654
  **refused**, not ignored — a green example running against the wrong stand-in is
620
655
  the expensive outcome.
621
656
 
622
- ### Fabricated list lengths
657
+ That hook is also why **a refusal the tag itself raises can't be asserted with
658
+ `expect { }.to raise_error`**: it happens before the example body, and rspec
659
+ records it as the example's failure rather than letting anything catch it — an
660
+ `around` hook included, since `example.run` returns normally there. To assert
661
+ one, call the helper in an **untagged** example, where the same refusal is
662
+ raised in the body: `expect { graphql_in_process }.to raise_error(...)`, and the
663
+ same for `graphql_fake` and `graphql_router`. `:wire` has no helper, so its
664
+ whole-example refusal can only be read off the failure — the per-graph one it
665
+ raises when a module runs is in the body, and assertable.
666
+
667
+ ### Fabricated list lengths and nulls
623
668
 
624
669
  `list_size` is how long an **unbounded** list is — an Integer exactly that many,
625
670
  a Range randomized within it, or a Hash saying it per list. A list with a
@@ -634,6 +679,23 @@ for the rest — `config.list_size = { "Row.tags" => 3, default: 1000 }` holds t
634
679
  inner list at 3 however large the outer one grows. Or cap it in the query
635
680
  (`tags(first: 3)`), where the query is yours to change.
636
681
 
682
+ `null_chance` takes the same two shapes, and the same keys: a number from 0 to 1
683
+ for every nullable field, or a Hash saying it per field with `default:` for the
684
+ rest. So the example about one missing value says only that —
685
+ `graphql_fake(null_chance: { "Person.nickname" => 1.0 })` — instead of nilling
686
+ everything else alongside it. A misspelled key is refused and spellchecked, the
687
+ way a pin's is.
688
+
689
+ **Both are keyed by field, and the key has to be one the option can reach**: a
690
+ type name (`"Person"`) is refused rather than read as the nearest field, a
691
+ `null_chance` key naming a non-null field is refused (nothing there ever comes
692
+ back null), and a `list_size` key naming a field that isn't a list likewise —
693
+ all three are keys that would validate clean, fabricate nothing and leave the
694
+ example green. `"default"` is always the fallback, never a field, so a schema
695
+ field actually called `default` is reachable only as `"Type.default"`. And a
696
+ [pin](#pins) on the same field beats both: a pinned value is used as written,
697
+ so it is neither nulled nor resized.
698
+
637
699
  **A list field whose name ends in `errors` fabricates empty** — `userErrors`,
638
700
  `errors`, `mutationErrors`. The Relay/Shopify payload
639
701
  (`placeOrder { order userErrors }`) is the ecosystem's mutation shape, and a
data/docs/upgrading.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # Upgrading
2
2
 
3
3
  [Regenerate](#regenerate-on-every-upgrade) whichever version you're on, then read
4
- the one section that is yours: from [0.7.4](#upgrading-from-074), from
5
- [0.7.3](#upgrading-from-073), from
4
+ the one section that is yours: from [0.7.6](#upgrading-from-076), from
5
+ [0.7.5](#upgrading-from-075), from
6
+ [0.7.4](#upgrading-from-074), from [0.7.3](#upgrading-from-073), from
6
7
  [0.7.1](#upgrading-from-071), from [0.7.0](#upgrading-from-070) or from
7
8
  [0.6.1](#upgrading-from-061). Coming from 0.6.0 or older, the path is that
8
9
  version's own upgrade notes — read them at the tag they shipped under
@@ -24,6 +25,45 @@ That's the reminder working, not a false alarm. Generation is deterministic, so
24
25
  the diff is exactly what the new version emits differently and nothing else —
25
26
  worth reading rather than rubber-stamping.
26
27
 
28
+ ## Upgrading from 0.7.6
29
+
30
+ Read the left column and skip what isn't yours; the
31
+ [changelog](../CHANGELOG.md) says why each one moved.
32
+
33
+ | applies if you… | what changed |
34
+ |---|---|
35
+ | set `namespace:` on any graph — `rake graph_weaver:graphs` lists them | its shared types module is `<Namespace>::Types`, not `<Namespace>::GraphQLTypes`. **Regenerate**, then move app code naming `<Namespace>::GraphQLTypes::X` to `<Namespace>::Types::X` — `srb tc` finds every site. Keep the old name with `types_module "GraphQLTypes"` in that graph's block, at the cost of the autoloadable layout below |
36
+ | run Rails, and have a graph whose `namespace:` is the constant its output path spells | that tree is [left to Zeitwerk](generated_modules.md#the-rule-in-rails) — autoloaded on first reference, reloaded with the app, and no longer required at boot, so booting stops defining its parent namespace chain. Nothing to do; the Railtie says which side each graph fell on at debug. Every other tree is hidden and required exactly as before |
37
+
38
+ Then regenerate, and the gate:
39
+
40
+ ```sh
41
+ rake graph_weaver:generate
42
+ rake graph_weaver:verify
43
+ ```
44
+
45
+ ## Upgrading from 0.7.5
46
+
47
+ A patch release. The regenerate is what changes behaviour — every module gains
48
+ the per-call event — and the rest reaches an app only where it already relied
49
+ on the old wording. Read the left column and skip what isn't yours; the
50
+ [changelog](../CHANGELOG.md) says why each one moved.
51
+
52
+ | applies if you… | what changed |
53
+ |---|---|
54
+ | subscribe to `execute.graph_weaver`, or read `GraphWeaver::LogSubscriber`'s line | a second event, [`operation.graph_weaver`](logging.md#instrumentation), covers one `execute` start to typed result — retries, backoff and the cast — and the request events nest inside it. **The info log line is the operation's now**: `failed GraphWeaver::CastError` where it read `ok`, present under a `graphql:` tag, one line per retried call; the per-attempt line writes at debug. A module generated by 0.7.5 emits neither until regenerated |
55
+ | spread a shared fragment on an interface or union that selects no `... on`, or exactly one — `grep -rn 'fragment .* on' app/graphql/fragments` | it [hoists](generated_modules.md#abstract-types) into `GraphQLTypes::<Fragment>` like every other shared fragment; app code naming `YourQuery::Result::Field` moves, and `srb tc` finds each site |
56
+ | run `rake graph_weaver:queries:check` in CI for a graph that names its own `schema` — `rake graph_weaver:graphs` lists them | it re-introspects that graph's server (the dump's url, or the client's url or schema class) where it used to check the committed file, so CI needs the same reach a single-schema app already had. The verdict names what it checked against either way |
57
+ | keep a composed supergraph on a graph that also names a `client` | `schema:refresh` steps over it with the recompose line where 0.7.5 could have overwritten it with the API schema |
58
+ | parse `rake graph_weaver:unused` output, or run it `STRICT=1` | a `before_action`-loaded `@result` is credited again, and the coordinate printed comes from the query, not from any word in the file |
59
+
60
+ Then regenerate, and the gate:
61
+
62
+ ```sh
63
+ rake graph_weaver:generate
64
+ rake graph_weaver:verify
65
+ ```
66
+
27
67
  ## Upgrading from 0.7.4
28
68
 
29
69
  Read the left column and skip what isn't yours; the
@@ -227,7 +267,7 @@ only one here that shows up in production rather than in your code.
227
267
  Nothing raises: a subscriber comparing it to an Integer just stops matching. **A
228
268
  subscriber that branched on a 4xx/5xx reads `:http_status` now**, which is nil
229
269
  in-process. The whole payload is a documented contract —
230
- [logging](logging.md#the-payload).
270
+ [logging](logging.md#the-request-payload).
231
271
  - **`respond_to?` on a result struct no longer answers true for a name that doesn't
232
272
  exist.** It used to say true for any near miss, which broke the standard
233
273
  duck-typing guard. **A branch that read the old answer now takes the other path**,
data/examples/README.md CHANGED
@@ -34,5 +34,7 @@ looks like an app:
34
34
  fellow stargazers.
35
35
 
36
36
  Regeneration introspects GitHub's schema (a few seconds, cached to a gitignored
37
- `github/schema.json`); `run.rb` alone never introspects, because the generated
38
- modules already carry their types.
37
+ `github/schema.json`); once that dump is there the repo's own `bin/generate`
38
+ refreshes these modules along with every other generated file, offline. `run.rb`
39
+ alone never introspects, because the generated modules already carry their
40
+ types.
@@ -6,15 +6,29 @@
6
6
  # the same workflow `rake graph_weaver:generate` runs in an app:
7
7
  #
8
8
  # examples/github/generate.rb
9
+ #
10
+ # bin/generate calls generate! too, off the cached dump, so these files are
11
+ # regenerated by the build like every other checked-in generated file.
9
12
  require_relative "setup"
10
13
 
11
- GraphWeaver.graph :github do
12
- schema GraphWeaver.client.schema
13
- queries File.join(__dir__, "queries")
14
- output File.join(__dir__, "generated")
14
+ module GithubExample
15
+ # The graph, declared once for both doors: this script, which introspects
16
+ # GitHub, and bin/generate, which passes the dump that introspection cached.
17
+ def self.generate!(schema_source)
18
+ GraphWeaver.graph :github do
19
+ schema schema_source
20
+ queries File.join(__dir__, "queries")
21
+ output File.join(__dir__, "generated")
22
+ end
23
+
24
+ GraphWeaver.generate!
25
+ GraphWeaver.changed_files
26
+ end
15
27
  end
16
28
 
17
- GraphWeaver.generate!
18
- changed = GraphWeaver.changed_files
19
- changed.each { |path| puts "wrote #{path}" }
20
- puts "already up to date" if changed.empty?
29
+ if $PROGRAM_NAME == __FILE__
30
+ GithubExample.connect!
31
+ changed = GithubExample.generate!(GraphWeaver.client.schema)
32
+ changed.each { |path| puts "wrote #{path}" }
33
+ puts "already up to date" if changed.empty?
34
+ end
@@ -1,7 +1,7 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- # Generated by GraphWeaver 0.7.4 — do not edit.
4
+ # Generated by GraphWeaver 0.7.7 — do not edit.
5
5
 
6
6
  module StarMutation
7
7
  extend T::Sig
@@ -117,7 +117,7 @@ module StarMutation
117
117
  "id" => GraphWeaver::Coerce.variable("id", OPERATION_NAME, id) { |v| GraphWeaver::Coerce.id(v, "ID") },
118
118
  }
119
119
 
120
- from_response(dispatch(variables, client:))
120
+ dispatch(variables, client:) { |raw| from_response(raw) }
121
121
  end
122
122
 
123
123
  sig { params(id: String, client: T.untyped).returns(Result).checked(:never) }
@@ -1,7 +1,7 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- # Generated by GraphWeaver 0.7.4 — do not edit.
4
+ # Generated by GraphWeaver 0.7.7 — do not edit.
5
5
 
6
6
  require "time"
7
7
 
@@ -257,7 +257,7 @@ module StargazersQuery
257
257
  "first" => GraphWeaver::Coerce.variable("first", OPERATION_NAME, first) { |v| GraphWeaver::Coerce.integer(v, "Int") },
258
258
  }
259
259
 
260
- from_response(dispatch(variables, client:))
260
+ dispatch(variables, client:) { |raw| from_response(raw) }
261
261
  end
262
262
 
263
263
  sig { params(owner: String, name: String, first: Integer, client: T.untyped).returns(Result).checked(:never) }
@@ -1,7 +1,7 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- # Generated by GraphWeaver 0.7.4 — do not edit.
4
+ # Generated by GraphWeaver 0.7.7 — do not edit.
5
5
 
6
6
  module StarredQuery
7
7
  extend T::Sig
@@ -150,7 +150,7 @@ module StarredQuery
150
150
  "first" => GraphWeaver::Coerce.variable("first", OPERATION_NAME, first) { |v| GraphWeaver::Coerce.integer(v, "Int") },
151
151
  }
152
152
 
153
- from_response(dispatch(variables, client:))
153
+ dispatch(variables, client:) { |raw| from_response(raw) }
154
154
  end
155
155
 
156
156
  sig { params(login: String, first: Integer, client: T.untyped).returns(Result).checked(:never) }
@@ -7,6 +7,7 @@
7
7
  #
8
8
  # examples/github/run.rb
9
9
  require_relative "setup"
10
+ GithubExample.connect!
10
11
 
11
12
  # the checked-in typed modules (regenerate: examples/github/generate.rb)
12
13
  Dir[File.join(__dir__, "generated", "*.rb")].sort.each { |file| require file }
@@ -4,15 +4,23 @@
4
4
  # Shared wiring for the GitHub example: auth and the client.
5
5
  require_relative "../../lib/graph_weaver"
6
6
 
7
- token = ENV["GITHUB_TOKEN"] || `gh auth token 2>/dev/null`.strip
8
- abort "need a token: `gh auth login`, or GITHUB_TOKEN=..." if token.empty?
9
-
10
- GraphWeaver.client = GraphWeaver.new(
11
- "https://api.github.com/graphql",
12
- auth: token,
7
+ module GithubExample
13
8
  # used by generate.rb (and any dynamic parse): the first introspection
14
9
  # of GitHub's large schema dumps here — gitignored, a few seconds once,
15
10
  # instant after. run.rb's checked-in generated modules never introspect,
16
11
  # so running it alone won't create this file.
17
- cache: File.join(__dir__, "schema.json"),
18
- )
12
+ SCHEMA_CACHE = File.join(__dir__, "schema.json")
13
+
14
+ # Requiring this file reaches nothing, so bin/generate can regenerate from
15
+ # SCHEMA_CACHE with no token; needing GitHub is this call.
16
+ def self.connect!
17
+ token = ENV["GITHUB_TOKEN"] || `gh auth token 2>/dev/null`.strip
18
+ abort "need a token: `gh auth login`, or GITHUB_TOKEN=..." if token.empty?
19
+
20
+ GraphWeaver.client = GraphWeaver.new(
21
+ "https://api.github.com/graphql",
22
+ auth: token,
23
+ cache: SCHEMA_CACHE,
24
+ )
25
+ end
26
+ end