graph_weaver 0.5.0 → 0.6.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +537 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +29 -4
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +75 -16
  8. data/docs/federation.md +206 -155
  9. data/docs/generated_modules.md +223 -166
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +35 -5
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +196 -155
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +243 -22
  16. data/graph_weaver.gemspec +16 -2
  17. data/lib/generators/graph_weaver/install_generator.rb +31 -16
  18. data/lib/graph_weaver/client.rb +52 -15
  19. data/lib/graph_weaver/codegen/aliases.rb +15 -8
  20. data/lib/graph_weaver/codegen/emit.rb +107 -42
  21. data/lib/graph_weaver/codegen/enum_type.rb +4 -3
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
  24. data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
  25. data/lib/graph_weaver/codegen.rb +382 -105
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +57 -13
  28. data/lib/graph_weaver/federation.rb +10 -22
  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 +45 -43
  38. data/lib/graph_weaver/internal/values.rb +181 -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 +6 -13
  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 +63 -18
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +126 -35
  50. data/lib/graph_weaver/tasks.rb +88 -36
  51. data/lib/graph_weaver/testing/cassette.rb +131 -78
  52. data/lib/graph_weaver/testing/coverage.rb +11 -15
  53. data/lib/graph_weaver/testing/failure.rb +14 -8
  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 +147 -840
  57. data/lib/graph_weaver/testing.rb +40 -83
  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 +221 -118
  63. metadata +17 -13
  64. data/CLAUDE.md +0 -161
  65. data/DECISIONS.md +0 -309
  66. data/Makefile +0 -23
  67. data/NOTES.md +0 -182
  68. data/PLAN.md +0 -115
  69. data/REVIEW.md +0 -946
  70. data/lib/graph_weaver/schemas.rb +0 -46
  71. data/lib/graph_weaver/selection.rb +0 -120
  72. data/lib/graph_weaver/testing/values.rb +0 -98
data/docs/upgrading.md CHANGED
@@ -1,18 +1,199 @@
1
- # Upgrading to 0.5.0
1
+ # Upgrading
2
2
 
3
- 0.5.0 is one large breaking release. Almost all of it is caught mechanically —
4
- the work is running three commands and following what they tell you.
3
+ ## Regenerate on every upgrade
4
+
5
+ **Any release can change what codegen emits.** Patch releases included — most of
6
+ them are fixes to a generated type, and a fix to a type is a change to the bytes.
7
+ 0.5.1 was a patch and moved three of them.
8
+
9
+ So `rake graph_weaver:generate` is part of upgrading the gem, every time, and
10
+ `rake graph_weaver:verify` is the detector: it fails when the checked-in Ruby
11
+ isn't what this version would write. Nothing beyond that is promised — there is
12
+ no "generated output is stable within a minor" rule to lean on. What each release
13
+ changed, and whether it needs a regenerate, is in the changelog.
14
+
15
+ A generated file's header names the release that wrote it, so the first `verify`
16
+ after an upgrade reports the tree as stale whether or not codegen actually
17
+ moved. That's the reminder working, not a false alarm.
18
+
19
+ Generation is deterministic, so the diff is exactly what the new version emits
20
+ differently and nothing else — worth reading rather than rubber-stamping.
21
+
22
+ ## Upgrading from 0.5.1
23
+
24
+ Much smaller than 0.5.0, and mostly mechanical. Three commands find most of it:
25
+
26
+ ```sh
27
+ rake graph_weaver:generate # 1. what codegen emits moved in several places
28
+ srb tc # 2. kwargs that got narrower are call-site errors
29
+ bundle exec rspec # 3. every deleted knob raises where it's still set
30
+ ```
31
+
32
+ The rest of this section is what those three don't catch.
33
+
34
+ ### Loose input coerces, so `coerce:` and `auto_coerce` are gone
35
+
36
+ `execute(first: params[:first])` converts the String to an `Integer` — for every
37
+ variable and every input-object field, with nothing to switch on. The old way of
38
+ buying that was `GraphWeaver.auto_coerce` or `register_scalar(…, coerce: true)`,
39
+ and both paid for it by **widening the emitted kwarg**, which switched off the
40
+ static check at every call site. Delete them:
41
+
42
+ ```ruby
43
+ GraphWeaver.auto_coerce = true # gone
44
+ GraphWeaver.register_scalar("Money", Money, coerce: true) # drop the coerce:
45
+ ```
46
+
47
+ Behavior is unchanged; the kwarg is not. It is now typed exactly as the schema
48
+ types it, so a call site passing a **literal** of the wrong type is a new
49
+ `srb tc` error — which is the point, since a literal is one you can just spell
50
+ right:
51
+
52
+ ```ruby
53
+ StargazersQuery.execute(first: "10") # srb tc error now
54
+ StargazersQuery.execute(first: params[:first]) # fine, and "10" becomes 10
55
+ ```
56
+
57
+ `cast:` is what a loose value converts through, so a custom scalar needs nothing
58
+ beyond the registration it already has. Bad input raises
59
+ `GraphWeaver::InputError` naming the variable, the operation and the value.
60
+
61
+ Two conversions got **stricter** at the same time, and either can bite an app
62
+ that was passing. A numeric string is now read as a wire format rather than as
63
+ Ruby source, so `"010"` is ten rather than eight and `"0x1f"` and `"1_0"` are
64
+ refused. And a `Boolean` refuses a String outright — every rule for `"0"` and
65
+ `"off"` is somebody's convention, so convert at the call site.
66
+
67
+ ### `nil` sends `null`
68
+
69
+ A variable passed `nil` now sends an explicit `null`; one left out is still left
70
+ out. That's what lets a mutation clear a field — and it changes what a kwarg fed
71
+ a possibly-missing value means:
72
+
73
+ ```ruby
74
+ UpdateProfile.execute!(bio: params[:bio]) # a missing param used to omit; now it clears the bio
75
+ ```
76
+
77
+ **Grep for kwargs fed straight from `params` or an optional attribute**, and
78
+ pass the keyword only when you mean it:
79
+
80
+ ```ruby
81
+ UpdateProfile.execute!(**(params[:bio] ? { bio: params[:bio] } : {}))
82
+ ```
83
+
84
+ Non-null variables are unaffected: they can't carry `null`, so `nil` there still
85
+ omits and the schema default applies. Input objects get the distinction only
86
+ where a Hash can express it — `coerce({nickname: nil})` sends null, `coerce({})`
87
+ omits, and a struct built with `.new` can't tell the two apart, so `nil` there
88
+ still means omit.
89
+
90
+ ### Renames
91
+
92
+ | before | after |
93
+ |---|---|
94
+ | `Retry.new(tries: n)`, `retries: { tries: n }` | `retries: n - 1` — one word everywhere, counting the attempts *after* the first, so `retries: 0` is one attempt and `GraphWeaver.new(url, retries: 3)` is four |
95
+ | `GraphWeaver.new(url, retries: { retries: 5, retry_codes: […] })` | `GraphWeaver.new(url, retries: 5, retry_codes: […])` — the other retry options sit beside the count; the Hash form read as a key nested in itself |
96
+ | `Retry.new(t, on: […])` | `Retry.new(t, retry_on: […])` |
97
+ | `Retry.new(t, base: 0.5, max: 30)` | `Retry.new(t, base_delay: 0.5, max_delay: 30)` — beside a count, `max: 30` read as a second, larger attempt count |
98
+ | `Codegen.generate(module_name:)` | `name:` — the spelling `GraphWeaver.parse` already used; `module_name:` now raises, naming its replacement |
99
+ | `Testing.config.null_chance = 0.3` | `graphql_fake(null_chance: 0.3)`, on the example that wants it |
100
+ | `Testing.config.mode = :literal` | `graphql_fake(values: :literal)`, likewise |
101
+ | `Testing::MODES` | `Testing::VALUE_STYLES` |
102
+ | `SchemaLoader.stale?(path)` | `SchemaLoader.diff(path).empty?` — and `diff` also names what moved |
103
+
104
+ The two `Testing.config` deletions are the ones worth a sentence. A suite-wide
105
+ `null_chance` answers a per-example question, so it nils an unrelated field one
106
+ run in ten, on a seed the failure doesn't name; move it onto the examples that
107
+ are *about* an empty state. (`config.default_mode` and the `graphql: :fake` tag
108
+ are untouched — the per-fake `mode:` became `values:` so the two can't be
109
+ confused for each other.) Every retry misspelling raises rather than being
110
+ ignored: the Hash form names its flat replacement, and a retry option passed
111
+ without `retries:` says so.
112
+
113
+ ### The internals moved behind `Internal`
114
+
115
+ The public surface is now what the docs name, what generated code calls, and the
116
+ `execute` slot; everything else sits under `GraphWeaver::Internal` or went
117
+ `private`, and a spec diffs the two so the next accidental promotion fails CI.
118
+ Nothing documented moved — skip this section unless `srb tc` or a
119
+ `NoMethodError` says otherwise.
120
+
121
+ What a suite might plausibly have reached for: the federation query planner and
122
+ its IR (`Internal::Planner`), the fake-value engine (`Internal::Values`), the
123
+ selection walk (`Internal::Selection` — so `FakeClient` no longer answers
124
+ `each_field` or `gather`), the cassette matching rules (`Internal::RequestKey`),
125
+ subgraph detection (was `Testing::Subgraphs`), `GraphWeaver.log` /
126
+ `.instrument` / `.filter_variables` (`Internal::Log` — `logger=`,
127
+ `instrumenter=` and `filter_parameters=` are unchanged), and
128
+ `Transport.operation_name` / `.mutation?` / `.log_tag`, which left the class you
129
+ subclass for `Internal::Wire`.
130
+
131
+ Two smaller edges. `SchemaDiff::Change`, `Cassette::Check`, `Coverage::Result`
132
+ and `InputStruct::Field` are `Data` now rather than `Struct`, so they hand out
133
+ no writers — read one, build a new one to change a field. And generated modules
134
+ keep their own plumbing to themselves: `DEFAULT_CLIENT`, `FIELDS` and `ONE_OF`
135
+ are emitted `private_constant`, so **regenerate**.
136
+
137
+ ### Behavior that changed under you
138
+
139
+ - **A mutation is no longer retried.** A timeout doesn't say whether the server
140
+ applied it, and a second `charge` is worse than a failed one. Pass
141
+ `retry_mutations: true` for an API whose mutations are idempotent.
142
+ - **A registration this schema can't match warns instead of failing
143
+ generation.** One registry serves a whole federated graph, so a name the
144
+ schema in hand doesn't declare may belong to the subgraph next door — see
145
+ [federation](federation.md#generating-for-a-federated-graph). Your typo is now
146
+ in the list `rake graph_weaver:generate` prints after the files, so read it.
147
+ - **`verify_generated!` fails when it finds no query documents.** A mistyped
148
+ `queries_paths` used to leave a CI gate green forever.
149
+ - **The local router refuses a `@fromContext` argument** rather than fetching
150
+ the field with it unset. Federation 2.8's `@context` machinery was on the
151
+ routing table's known list, so the argument was read and dropped. Per query,
152
+ like `@interfaceObject`: a subtree one subgraph answers whole still runs.
153
+ - **A `#trace` assertion may see one entry fewer.** Two `@requires` field sets
154
+ crossing into the same subgraph on the same `@key` now ride one entity fetch,
155
+ the way Apollo's do.
156
+ - **Fabricating a custom scalar registered as a class of your own needs a pin
157
+ for the type** — `Testing.config.overrides = { "Money" => "12.00" }`, or the
158
+ same key on one example's `graphql_fake`. Without one, `FakeClient` and
159
+ cassette anonymization refuse rather than feeding your cast a `"Money-1"`
160
+ placeholder. Scalars registered as `Time`, `Date`, `Integer`, `Float`,
161
+ `String` or `T::Boolean` need nothing.
162
+ - **Re-run `rake graph_weaver:cassettes:anonymize`** on any committed cassette
163
+ holding a registered custom scalar: the anonymizer used to write a value the
164
+ generated codec couldn't read back.
165
+ - **Generation refuses four more things**, each naming its fix — a
166
+ `register_scalar` whose Ruby type nothing can build out of JSON (`BigDecimal`,
167
+ classically: give it a `cast:`), a result key that would shadow a constant the
168
+ file uses, an enum value that camelizes to nothing, and a narrowed fragment
169
+ whose `__typename` sits behind `@skip`/`@include`.
170
+
171
+ ## Upgrading to 0.5.0
172
+
173
+ 0.5.0 is one large breaking release. Almost all of it is caught mechanically,
174
+ in this order:
5
175
 
6
176
  ```sh
7
- rake graph_weaver:generate # 1. regenerate; the emitted call shape changed
8
- srb tc # 2. every call site that moved is now a type error
9
- rake graph_weaver:verify # 3. fails until the tree is regenerated
177
+ # 1. rename the path settings first — generate won't load without them
178
+ # (queries_path -> queries_paths, generated_path -> generated_paths,
179
+ # fragments_path -> fragments_paths; see "Path settings are lists" below)
180
+
181
+ bundle exec tapioca gem graph_weaver # 2. regenerate the RBI
182
+ rake graph_weaver:generate # 3. the emitted call shape changed
183
+ srb tc # 4. every call site that moved is an error
184
+ rake graph_weaver:verify # 5. fails until the tree is regenerated
10
185
  ```
11
186
 
12
- Generated code is `# typed: strict`, so step 2 finds the call sites for you.
13
- The rest of this page is what a typechecker can't see.
187
+ **Step 2 is not optional.** Against the 0.4.6 RBI, `srb tc` reports errors
188
+ pointing into your `generated/` directory `QueryModule`, `client_for`,
189
+ `check_envelope!` — which read as though codegen emitted broken Ruby. It
190
+ didn't; sorbet is checking new generated code against the old gem's types.
191
+ Regenerate the RBI and what remains is only your own call sites.
192
+
193
+ Generated code is `# typed: strict`, so step 4 finds those for you. The rest of
194
+ this page is what a typechecker can't see.
14
195
 
15
- ## `execute` means one thing now
196
+ ### `execute` means one thing now
16
197
 
17
198
  Every client answers the same call — `execute(query, variables:, operation_name:)`,
18
199
  returning the raw response hash. `Client` used to spell something else under
@@ -30,7 +211,9 @@ GraphWeaver.run(source, query, **vars) # after
30
211
  ```
31
212
 
32
213
  **This one is worth grepping for.** `Client#execute` still exists, so a stale
33
- call fails at runtime rather than at typecheck: `rg '\.execute!?\(' --type ruby`
214
+ call fails at runtime rather than at typecheck as do `GraphWeaver.execute`
215
+ and `GraphWeaver.reset_scalars!`, which are simply gone and will not be flagged
216
+ until the RBI is regenerated (step 2): `rg '\.execute!?\(' --type ruby`
34
217
  and check each hit is passing `variables:` rather than loose kwargs.
35
218
 
36
219
  A generated module takes its per-call client as a **keyword**:
@@ -42,7 +225,7 @@ PersonQuery.execute(client: some_client, id: "1") # after
42
225
 
43
226
  `GraphWeaver.resolve_transport` is gone; nothing needs unwrapping any more.
44
227
 
45
- ## Path settings are lists
228
+ ### Path settings are lists
46
229
 
47
230
  `queries_paths`, `generated_paths`, `fragments_paths` — every entry is read.
48
231
  Assigning a String still works, so the change is the name:
@@ -54,9 +237,10 @@ GraphWeaver.queries_paths = "app/graphql/queries" # after
54
237
 
55
238
  `schema_path` stays singular: one run reads one schema.
56
239
 
57
- ## One reset
240
+ ### One reset
58
241
 
59
- `GraphWeaver.reset_registrations!` is the clean slate between tests. The four
242
+ `GraphWeaver.reset_registrations!` is the clean slate between tests, or between
243
+ generations for different schemas. The four
60
244
  narrow ones moved to where they live:
61
245
 
62
246
  ```ruby
@@ -65,23 +249,59 @@ GraphWeaver::Codegen.reset_scalars! # after (also reset_enums!, clear_scalar
65
249
  # reset_type_helpers!)
66
250
  ```
67
251
 
68
- ## Smaller renames
252
+ ### Generated names come from the response key, not the type
253
+
254
+ Nested structs used to be named for the GraphQL *type* they were cast from;
255
+ they are now named for the **response key that selects them**, camelized, and
256
+ the constant path reads like the query. The typechecker finds the call sites in
257
+ a `# typed: true` file (an unresolved constant is an `srb tc` error); in a
258
+ `# typed: false` file it is `uninitialized constant` at runtime, so grep for
259
+ `::Result::` there.
260
+
261
+ | selection | before (type) | after (key) |
262
+ |---|---|---|
263
+ | `person { pets { name } }` | `PersonQuery::Result::Person::Pet` | `PersonQuery::Result::Person::Pets` |
264
+ | `payrollRisk { score }` | `…::Result::RiskAssessment` | `…::Result::PayrollRisk` |
265
+ | `_entities(…) { ... on Product { … } }` | `…::Result::Product` | `…::Result::Entities::Product` |
266
+
267
+ The key is used verbatim — no pluralization, so a list field `pets` is `Pets`.
268
+ To pick the name yourself, alias the field: `pet: pets { name }` generates
269
+ `Pet`. Union and interface members keep their type-condition names, nested in
270
+ the container the field names. The payoff is that adding, removing or
271
+ reordering an unrelated selection can never rename a struct you reference.
272
+
273
+ **Enums moved out of the result tree.** Every schema enum a query touches is one
274
+ Ruby type in the shared module, `GraphQLTypes::Species`, so a value read from
275
+ one query hands straight into another's variable. A query module aliases the
276
+ enums its *variables* use (`AddPetMutation::Species` still works); an enum
277
+ reached only through a result is no longer nested under the struct that
278
+ carries it — `SearchQuery::Result::Search::Species` is `GraphQLTypes::Species`.
279
+
280
+ ### Smaller renames
69
281
 
70
282
  | before | after |
71
283
  |---|---|
72
- | `response.ok?` | `response.success?` |
73
284
  | `Testing.config.auto_fake = true` | `Testing.config.default_mode = :fake` |
74
- | `register_scalar(…, coerce: :to_s)` | `coerce: true`, or a `cast:`/`serialize:` pair |
75
285
  | a mutation's `…Query` module | `…Mutation` |
286
+ | `graphql: :none` (rspec tag) | `graphql: false` |
287
+
288
+ **The shared types module was three, and is now one.** `GraphQLInputs`,
289
+ `GraphQLEnums` and `GraphQLUnions` are all `GraphQLTypes`, and the files move
290
+ with them — `generated/inputs/` becomes `generated/types/`. The three settings
291
+ that named them (`inputs_module=`, `enums_module=`, `unions_module=`) are one
292
+ `types_module=`. Regenerating writes the new tree; delete the old directory,
293
+ which pruning leaves behind empty.
76
294
 
77
- `Testing::LiveSchema` is gone. If your client points at a different API than the
78
- schema class your specs run in-process, name it once:
295
+ If your specs run one schema class in-process while your client points at a
296
+ different API, name it per example, since a federated suite runs more than
297
+ one:
79
298
 
80
299
  ```ruby
81
- GraphWeaver::Testing.config.schema = MySchema
300
+ graphql_in_process(MySchema) # in the example
301
+ GraphWeaver::Testing.config.schema = MySchema # or once, for the whole suite
82
302
  ```
83
303
 
84
- ## Registering from Rails
304
+ ### Registering from Rails
85
305
 
86
306
  A registration naming one of your own constants belongs in a `to_prepare` block
87
307
  — the same place the in-process client goes, and for the same reason:
@@ -95,9 +315,10 @@ end
95
315
  ```
96
316
 
97
317
  Generation depends on `:environment`, which runs `to_prepare` too, so the
98
- registration is in place before it emits.
318
+ registration is in place before it emits — and at boot the generated files
319
+ load from a `to_prepare` block of their own, after yours.
99
320
 
100
- ## If you use the federation router
321
+ ### If you use the federation router
101
322
 
102
323
  Detection only sees *loaded* schema classes, and Rails does not eager load for
103
324
  rake or in the default test environment. Both are one line:
data/graph_weaver.gemspec CHANGED
@@ -7,13 +7,27 @@ Gem::Specification.new do |s|
7
7
  s.description = "Your .graphql files, compiled into Sorbet types — and the fakes to test them."
8
8
  # ".yardopts" explicitly: `git ls-files *` skips dotfiles, and
9
9
  # rubydoc.info needs it shipped to render docstrings as markdown
10
- s.files = `git ls-files * ':!:spec' ':!:sorbet' ':!:bin' ':!:examples'`.split("\n") + [".yardopts"]
10
+ # CLAUDE.md/PLAN.md/REVIEW.md/NOTES.md/DECISIONS.md are written for whoever
11
+ # works on the gem, not whoever installs it — and REVIEW.md carries examples
12
+ # from before the API it describes was rewritten
13
+ s.files = `git ls-files * ':!:spec' ':!:sorbet' ':!:bin' ':!:examples' \
14
+ ':!:CLAUDE.md' ':!:PLAN.md' ':!:REVIEW.md' ':!:NOTES.md' \
15
+ ':!:DECISIONS.md' ':!:Makefile' ':!:design'`.split("\n") + [".yardopts"]
11
16
  s.homepage = "https://github.com/dpep/graph_weaver"
12
17
  s.license = "MIT"
13
18
  s.name = "graph_weaver"
14
- s.summary = "GraphWeaver"
19
+ # rubygems.org shows summary as the headline, description below it
20
+ s.summary = "A typed GraphQL client for Ruby"
15
21
  s.version = GraphWeaver::VERSION
16
22
 
23
+ s.metadata = {
24
+ "bug_tracker_uri" => "#{s.homepage}/issues",
25
+ "changelog_uri" => "#{s.homepage}/blob/main/CHANGELOG.md",
26
+ "documentation_uri" => "#{s.homepage}/tree/main/docs",
27
+ "rubygems_mfa_required" => "true",
28
+ "source_code_uri" => s.homepage,
29
+ }
30
+
17
31
  s.required_ruby_version = ">= 3.3"
18
32
 
19
33
  # 2.6.7 fills defaulted directive arguments when building from SDL
@@ -31,17 +31,14 @@ module GraphWeaver
31
31
  argument :source, type: :string, banner: "SOURCE",
32
32
  desc: "what you'd pass to GraphWeaver.new: an endpoint url, a graphql-ruby schema class, or a schema dump path"
33
33
 
34
+ # the default is SchemaLoader's, not one restated here — an --auth the
35
+ # generator omits from the dump is one the schema tasks then can't find
34
36
  class_option :auth, type: :string,
35
- desc: "name of the ENV var holding the auth token (url only) — default GRAPHWEAVER_AUTH"
37
+ desc: "name of the ENV var holding the auth token (url only) — " \
38
+ "default #{GraphWeaver::SchemaLoader::DEFAULT_AUTH_ENV}"
36
39
  class_option :schema, type: :boolean, default: true,
37
40
  desc: "write the schema dump codegen reads"
38
41
 
39
- # a Ruby constant path names a schema class; anything that is neither
40
- # this nor a url is taken as a path to a dump
41
- CONSTANT = /\A[A-Z]\w*(::[A-Z]\w*)*\z/
42
-
43
- DEFAULT_AUTH = "GRAPHWEAVER_AUTH"
44
-
45
42
  # Before anything is written: a mistyped source or a flag that doesn't
46
43
  # apply to it is a mistake in the command just typed, so say so there
47
44
  # rather than at boot, three files later.
@@ -59,8 +56,11 @@ module GraphWeaver
59
56
  create_file "config/initializers/graph_weaver.rb", initializer
60
57
  end
61
58
 
59
+ # fragments too: the editor config below globs it, and a shared fragment
60
+ # then has an obvious home rather than being invented later
62
61
  def create_layout
63
62
  create_file File.join(GraphWeaver.queries_paths.first, ".keep"), ""
63
+ create_file File.join(GraphWeaver.fragments_paths.first, ".keep"), ""
64
64
  create_file File.join(GraphWeaver.generated_paths.first, ".keep"), ""
65
65
  end
66
66
 
@@ -88,10 +88,18 @@ module GraphWeaver
88
88
  rescue StandardError => e
89
89
  # the files above are the valuable part — don't lose them to a bad
90
90
  # token or an unreachable host
91
+ @schema_failed = true
91
92
  say_status :failed, "#{e.message} — retry with `#{refresh_command}`", :red
92
93
  end
93
94
 
94
95
  def next_steps
96
+ # generation reads the dump, so without one the step below can't run —
97
+ # say that next to it rather than leaving the red line above to be
98
+ # scrolled past. A re-run that already has a dump is not blocked.
99
+ if @schema_failed && !GraphWeaver::SchemaLoader.locate_path
100
+ say "\nThere's no schema dump yet, so `rake graph_weaver:generate` has nothing to read."
101
+ end
102
+
95
103
  say <<~TEXT
96
104
 
97
105
  Write a query in #{GraphWeaver.queries_paths.first}, then:
@@ -140,14 +148,16 @@ module GraphWeaver
140
148
  end
141
149
  end
142
150
 
143
- # Which of GraphWeaver.new's source forms this is the url test is
144
- # its own, so the generator and the client can't disagree about what
145
- # counts as one.
151
+ # Which of GraphWeaver.new's source forms this is. Neither test is its
152
+ # own a url is whatever the client calls one, a constant path whatever
153
+ # codegen will spell — so the generator can't disagree with either about
154
+ # what it just wrote an initializer for. Anything that is neither is
155
+ # taken as a path to a dump.
146
156
  def form
147
157
  @form ||=
148
158
  if source.match?(GraphWeaver::Client::URL)
149
159
  :url
150
- elsif source.match?(CONSTANT)
160
+ elsif source.match?(GraphWeaver::Codegen::CONSTANT_NAME)
151
161
  :schema_class
152
162
  else
153
163
  :path
@@ -174,10 +184,14 @@ module GraphWeaver
174
184
  # writes the conventional one.
175
185
  def schema_path = (form == :path) ? source : GraphWeaver.schema_path
176
186
 
177
- def auth_var = options[:auth] || DEFAULT_AUTH
187
+ def auth_var = options[:auth] || GraphWeaver::SchemaLoader::DEFAULT_AUTH_ENV
178
188
 
189
+ # The command just typed, retyped. One rule for every source form, and
190
+ # the only one that always works: the files already written come back
191
+ # "identical", and --auth rides along — where schema:refresh has no flag
192
+ # for it, and with no dump written has no url to read either.
179
193
  def refresh_command
180
- (form == :url) ? "rake graph_weaver:schema:refresh" : "rails g graph_weaver:install #{source}"
194
+ "rails g graph_weaver:install #{source}#{" --auth #{options[:auth]}" if options[:auth]}"
181
195
  end
182
196
 
183
197
  def initializer
@@ -243,15 +257,16 @@ module GraphWeaver
243
257
  end
244
258
 
245
259
  # fragments are in documents: too — without them an editor reports
246
- # `Unknown fragment` on any query that spreads a shared one
260
+ # `Unknown fragment` on any query that spreads a shared one. The glob is
261
+ # codegen's, so the editor validates exactly the files codegen reads.
247
262
  def editor_config
248
263
  <<~YAML
249
264
  # Autocomplete and validation for .graphql files in VS Code / RubyMine.
250
265
  # https://github.com/dpep/graph_weaver/blob/main/docs/editors.md
251
266
  schema: #{schema_path}
252
267
  documents:
253
- - #{GraphWeaver.queries_paths.first}/**/*.{graphql,gql}
254
- - #{GraphWeaver.fragments_paths.first}/**/*.{graphql,gql}
268
+ - #{File.join(GraphWeaver.queries_paths.first, GraphWeaver::Codegen::DOCUMENT_GLOB)}
269
+ - #{File.join(GraphWeaver.fragments_paths.first, GraphWeaver::Codegen::DOCUMENT_GLOB)}
255
270
  YAML
256
271
  end
257
272
  end
@@ -36,17 +36,32 @@ class GraphWeaver::Client
36
36
 
37
37
  URL = %r{\Ahttps?://}i
38
38
 
39
- def initialize(source, auth: nil, headers: {}, retries: false, transport: nil, cache: nil, ttl: nil,
40
- open_timeout: nil, read_timeout: nil, context: nil, &middleware)
39
+ # refused from two branches a url source, and a schema source with
40
+ # nothing to hand a context to — so the two can't word it differently
41
+ CONTEXT_IN_PROCESS = "context: applies to a schema class executing in-process"
42
+
43
+ # the whole rule, said wherever a retry option is refused
44
+ RETRY_RULE = "retries: is how many attempts follow the first; the other retry options sit beside it"
45
+ private_constant :CONTEXT_IN_PROCESS, :RETRY_RULE
46
+
47
+ def initialize(source, auth: nil, headers: {}, transport: nil, cache: nil, ttl: nil,
48
+ open_timeout: nil, read_timeout: nil, context: nil,
49
+ retries: false, backoff: nil, base_delay: nil, max_delay: nil, jitter: nil, retry_on: nil,
50
+ retry_if: nil, retry_codes: nil, retry_mutations: nil, sleeper: nil, &middleware)
41
51
  check_source!(source)
42
52
 
53
+ # Retry's options, spelled the same and passed straight through; nil
54
+ # is "not given", so their defaults stay in Retry alone
55
+ retry_options = { backoff:, base_delay:, max_delay:, jitter:, retry_on:, retry_if:,
56
+ retry_codes:, retry_mutations:, sleeper: }.compact
57
+
43
58
  if source.is_a?(String) && source.match?(URL)
44
- raise ArgumentError, "context: applies to a schema class executing in-process" if context
59
+ raise ArgumentError, CONTEXT_IN_PROCESS if context
45
60
 
46
61
  built = build_transport(source, auth:, headers:, kind: transport, open_timeout:, read_timeout:, &middleware)
47
- @transport = wrap_retries(built, retries)
62
+ @transport = wrap_retries(built, retries, retry_options)
48
63
  else
49
- if auth || middleware || retries || open_timeout || read_timeout
64
+ if auth || middleware || retries || open_timeout || read_timeout || !retry_options.empty?
50
65
  raise ArgumentError, "auth:/retries:/timeouts/middleware apply to a url — got a schema source"
51
66
  end
52
67
  if transport.is_a?(Symbol)
@@ -65,7 +80,7 @@ class GraphWeaver::Client
65
80
  if context && !(source.is_a?(Module) && transport.nil?)
66
81
  # nothing would ever read it — a dump has no resolvers, and an
67
82
  # explicit transport carries its own
68
- raise ArgumentError, "context: applies to a schema class executing in-process"
83
+ raise ArgumentError, CONTEXT_IN_PROCESS
69
84
  end
70
85
 
71
86
  # InProcess adds context:, logging and branded errors to the bare
@@ -76,6 +91,7 @@ class GraphWeaver::Client
76
91
 
77
92
  @cache = cache
78
93
  @ttl = ttl
94
+ @schema_lock = Mutex.new
79
95
  end
80
96
 
81
97
  # The transport queries run through: a url-built transport, an
@@ -85,15 +101,21 @@ class GraphWeaver::Client
85
101
  attr_reader :transport
86
102
 
87
103
  # transport, when this client must be able to execute
88
- def transport!
104
+ private def transport!
89
105
  transport or raise GraphWeaver::Error,
90
106
  "this client has no transport (built from a schema dump) — pass a url or transport:"
91
107
  end
92
108
 
93
109
  # The schema, introspecting through the transport on first use (cached
94
110
  # per the client's cache:/ttl:) unless one was given up front.
111
+ #
112
+ # Locked because a cold Puma process serves its first requests
113
+ # concurrently: a bare ||= there is one full introspection round trip per
114
+ # in-flight thread, each of them also writing the cache file.
95
115
  def schema
96
- @schema ||= GraphWeaver::SchemaLoader.introspect(transport!, cache: @cache, ttl: @ttl)
116
+ @schema_lock.synchronize do
117
+ @schema ||= GraphWeaver::SchemaLoader.introspect(transport!, cache: @cache, ttl: @ttl)
118
+ end
97
119
  end
98
120
 
99
121
  # The client contract, same as every transport: a query and its
@@ -146,6 +168,11 @@ class GraphWeaver::Client
146
168
  def build_transport(url, auth:, headers:, kind:, open_timeout: nil, read_timeout: nil, &middleware)
147
169
  headers = headers.dup
148
170
  if auth
171
+ unless auth.is_a?(String)
172
+ raise ArgumentError, "auth: takes a token string, got #{auth.class} — other headers go in " \
173
+ "headers:, and a token that rotates goes in the Faraday middleware block"
174
+ end
175
+
149
176
  headers["Authorization"] ||= auth.include?(" ") ? auth : "Bearer #{auth}"
150
177
  end
151
178
 
@@ -159,7 +186,7 @@ class GraphWeaver::Client
159
186
  GraphWeaver::Transport::HTTP.new(url, headers:, **timeouts)
160
187
  end
161
188
 
162
- GraphWeaver.log(:info) { "transport: #{transport.class} -> #{url}" }
189
+ GraphWeaver::Internal::Log.log(:info) { "transport: #{transport.class} -> #{url}" }
163
190
  transport
164
191
  end
165
192
 
@@ -194,13 +221,23 @@ class GraphWeaver::Client
194
221
  GraphWeaver::Transport::Faraday.new(url, headers:, **timeouts, &middleware)
195
222
  end
196
223
 
197
- # retries: is off by default — true for Retry defaults, or a
198
- # Hash of its options
199
- def wrap_retries(transport, retries)
224
+ # retries: is off by default — a count, or true for Retry's default
225
+ # count. Without it nothing wraps the transport, so a retry option on
226
+ # its own would quietly do nothing.
227
+ def wrap_retries(transport, retries, options)
200
228
  case retries
201
- when true then GraphWeaver::Retry.new(transport)
202
- when false, nil then transport
203
- else GraphWeaver::Retry.new(transport, **retries)
229
+ when Integer then GraphWeaver::Retry.new(transport, retries:, **options)
230
+ when true then GraphWeaver::Retry.new(transport, **options)
231
+ when false, nil
232
+ raise ArgumentError, "#{options.keys.first}: needs retries: — #{RETRY_RULE}" if options.any?
233
+
234
+ transport
235
+ when Hash
236
+ # it used to take a Hash of Retry options, which read as a key nested in itself
237
+ flat = retries.map { |key, value| "#{key}: #{value.inspect}" }.join(", ")
238
+ raise ArgumentError, "retries: no longer takes a Hash — pass GraphWeaver.new(url, #{flat})"
239
+ else
240
+ raise ArgumentError, "#{RETRY_RULE} — got #{retries.inspect}"
204
241
  end
205
242
  end
206
243
  end
@@ -51,14 +51,19 @@ class GraphWeaver::Codegen
51
51
  # since a module and the type it queries can share a name (module Query
52
52
  # on type Query would otherwise stutter).
53
53
  def qualify(node, message)
54
- return message if @module_name.nil? || @module_name == node.graphql_type
54
+ return message if @name.nil? || @name == node.graphql_type
55
55
 
56
- "#{@module_name}: #{message}"
56
+ "#{@name}: #{message}"
57
57
  end
58
58
 
59
+ # An alias emits a plain instance method, so it is held to the same bar
60
+ # as a wire field's prop: a name the struct already answers to would be
61
+ # silently overridden, and `hash` or `inspect` breaks the object rather
62
+ # than the file.
59
63
  def check_alias_name!(node, name)
60
64
  taken = node.fields.any? { |f| f.prop == name } ||
61
- ALIAS_RESERVED.include?(name) || RUBY_KEYWORDS.include?(name)
65
+ STRUCT_METHODS.include?(name) || ALIAS_RESERVED.include?(name) ||
66
+ RUBY_KEYWORDS.include?(name)
62
67
  return unless taken
63
68
 
64
69
  raise GraphWeaver::Error,
@@ -70,9 +75,10 @@ class GraphWeaver::Codegen
70
75
  GraphWeaver::Codegen.type_registry[graphql_name]&.dig(:aliases) || {}
71
76
  end
72
77
 
73
- # methods every generated struct already answers to; Ruby keywords are
74
- # checked alongside (RUBY_KEYWORDS is defined by the class this mixes into)
75
- ALIAS_RESERVED = %w[from_h serialize to_h].to_set.freeze
78
+ # The CLASS methods a generated struct defines; STRUCT_METHODS covers the
79
+ # instance side, and both are checked with RUBY_KEYWORDS alongside (all
80
+ # three are defined by the class this mixes into).
81
+ ALIAS_RESERVED = %w[from_h].to_set.freeze
76
82
  # list selectors — pick one element out of a list-typed hop, always nilable
77
83
  # (the list may be empty). Everything else is a field prop.
78
84
  LIST_SELECTORS = %w[first last].freeze
@@ -80,6 +86,7 @@ class GraphWeaver::Codegen
80
86
  # ever satisfy it, so it's a typo (or a wire-cased name), not a path that
81
87
  # doesn't fit this query. optional: skips the latter, never this.
82
88
  UnknownSegment = Class.new(GraphWeaver::Error)
89
+ private_constant :ALIAS_RESERVED, :LIST_SELECTORS, :UnknownSegment
83
90
 
84
91
  # Walk a dotted path through this struct's selected shape, building the
85
92
  # delegator expression (`meta&.tag`, `_entities.first&.name`) and its return
@@ -131,7 +138,7 @@ class GraphWeaver::Codegen
131
138
  unless field
132
139
  check_segment_exists!(node, name, obj, seg)
133
140
  props = obj.fields.map(&:prop)
134
- suggestion = GraphWeaver.did_you_mean(props, seg)
141
+ suggestion = GraphWeaver::Internal::Util.did_you_mean(props, seg)
135
142
  hint = suggestion ? " — did you mean '#{suggestion}'?" : " (have: #{props.join(", ")})"
136
143
  raise GraphWeaver::Error,
137
144
  "alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a selected field#{hint}"
@@ -163,7 +170,7 @@ class GraphWeaver::Codegen
163
170
  hint = if prop != seg && known.include?(prop)
164
171
  # paths are the Ruby prop chain, not the GraphQL one — the classic miss
165
172
  " — GraphQL fields generate snake_case props; use '#{prop}'"
166
- elsif (suggestion = GraphWeaver.did_you_mean(known, prop))
173
+ elsif (suggestion = GraphWeaver::Internal::Util.did_you_mean(known, prop))
167
174
  " — did you mean '#{suggestion}'?"
168
175
  else
169
176
  " (has: #{known.sort.join(", ")})"