graph_weaver 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1357 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -136
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -251
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -98
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +617 -264
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +12 -6
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +21 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +15 -1
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +820 -57
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +59 -7
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +186 -62
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +194 -28
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +31 -6
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +74 -18
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +398 -170
  62. metadata +20 -3
data/docs/transports.md CHANGED
@@ -1,10 +1,18 @@
1
1
  # Transports
2
2
 
3
- A *client* is anything with `execute(query, variables:)` whose result
3
+ A *client* is anything with `execute(query, variables:, operation_name:)` whose result
4
4
  `to_h`s into `{"data" => ..., "errors" => ...}` — from a full
5
- `GraphWeaver::Client` down to a schema class (in-process execution), a
6
- [FakeClient](testing.md), or anything you write. Every slot that takes a
7
- client accepts any of them.
5
+ `GraphWeaver::Client` down to a schema class
6
+ ([in-process execution](getting_started.md#your-apps-own-schema-in-process)
7
+ typed access to your own app's API, no socket), a [FakeClient](testing.md), or
8
+ anything you write. Every slot that takes a client accepts any of them.
9
+
10
+ **Anything holding a schema parses against it.** `client.parse(query)`, and
11
+ the same on `InProcess`, `FakeClient` and `Testing::Router` — a typed module
12
+ bound to that schema, running on that object, without naming either. It sits
13
+ on top of the contract rather than in it: `Retry` wraps a client and holds no
14
+ schema, so it has no `parse`, and a bare schema class fills the client slot
15
+ without one. `load_queries!` is the same rule over a directory.
8
16
 
9
17
  A *transport* is the network end of that contract — GraphQL-over-HTTP. The bundled
10
18
  two — `Transport::HTTP` (net/http, zero dependencies, loaded by default)
@@ -22,31 +30,31 @@ Most apps need one line:
22
30
  github = GraphWeaver.new("https://api.example.com/graphql", auth: ENV["API_TOKEN"])
23
31
  ```
24
32
 
25
- `GraphWeaver.new` builds a [`Client`](real_world.md): the best transport
26
- with auth applied (exposed as `client.transport`), the schema
27
- introspected lazily, and `parse`/`execute` bound to both.
33
+ `GraphWeaver.new` builds a [`Client`](real_world.md): a transport with
34
+ auth applied (exposed as `client.transport`), the schema introspected
35
+ lazily, and `parse`/`run` bound to both. A `Client` answers the client
36
+ contract itself, so it goes anywhere a transport does — `Retry.new(client)`,
37
+ `subgraphs:`, a cassette recorder.
28
38
 
29
39
  - `auth:` — a token; "Bearer" is assumed unless the string carries its own
30
40
  scheme (`"Basic dXNlcjpwYXNz..."`)
41
+ - `transport:` — `:http` (the default) or `:faraday`
31
42
  - `headers:` — anything else (API keys, custom headers)
32
43
  - `retries:` — off by default; `true` for a `Retry` with defaults,
33
44
  or a Hash of its options
45
+ - `open_timeout:` / `read_timeout:` — seconds, defaulting to 10 and 30 on
46
+ either transport
34
47
  - `cache:` / `ttl:` — schema introspection caching (see
35
48
  [real world](real_world.md)); url clients only — a schema source never
36
49
  introspects, so passing them raises
37
50
  - a block customizes the Faraday connection (Faraday only — raises without it)
38
51
 
52
+ What you pass is what you get; the client logs which transport it built at
53
+ `info`.
54
+
39
55
  To wire generated modules that don't bake a client, make it the app's
40
56
  default: `GraphWeaver.client = github`. Anything satisfying the execute
41
- contract works there — testing's auto_fake swaps in a fake per example.
42
-
43
- **Transport pick**: `Transport::Faraday` when the app already loads
44
- faraday (its middleware/proxy/timeout ecosystem comes along), the
45
- zero-dependency `Transport::HTTP` otherwise. Detection is `defined?(Faraday)` —
46
- deliberately *not* a require: faraday rides along transitively in most
47
- bundles (stripe, octokit, ...), and try-requiring would silently switch
48
- transports on apps that never chose it. With faraday under
49
- `require: false`, load it before building the client.
57
+ contract works there — testing's `graphql:` tag swaps in a client per example.
50
58
 
51
59
  ## Building blocks
52
60
 
@@ -54,16 +62,29 @@ The client is convenience, not the only door — construct and assign
54
62
  yourself for full control:
55
63
 
56
64
  ```ruby
57
- # zero-dependency Net::HTTP — persistent (keep-alive) connection,
58
- # mutex-serialized; timeouts raise retriable TransportError
65
+ # zero-dependency Net::HTTP — a pool of persistent (keep-alive)
66
+ # connections; timeouts raise retriable TransportError
59
67
  GraphWeaver::Transport::HTTP.new(
60
68
  url,
61
69
  headers: { ... },
62
70
  open_timeout: 10, read_timeout: 30, # seconds (the defaults)
63
71
  keep_alive_timeout: 2, # idle window before reconnecting
72
+ pool_size: 5, # concurrent requests in flight
73
+ # (default: RAILS_MAX_THREADS, else 5)
74
+
75
+ # TLS, forwarded to Net::HTTP.start — a private CA, or mTLS, without
76
+ # reaching for Faraday. Passing any of these to an http:// url raises
77
+ # rather than quietly doing nothing.
78
+ ca_file: "/etc/ssl/private-ca.pem", # or ca_path: for a directory
79
+ cert: OpenSSL::X509::Certificate.new(File.read("client.crt")),
80
+ key: OpenSSL::PKey::RSA.new(File.read("client.key")),
81
+ verify_mode: OpenSSL::SSL::VERIFY_PEER, # the default; VERIFY_NONE to skip
64
82
  )
65
83
 
66
- # Faraday: a url (+ optional middleware block), or a ready connection
84
+ # Faraday: a url (+ optional middleware block), or a ready connection.
85
+ # Timeouts default to the same 10/30 as Transport::HTTP — without them
86
+ # Faraday inherits net/http's 60s/60s.
87
+ GraphWeaver::Transport::Faraday.new(url, open_timeout: 10, read_timeout: 30)
67
88
  GraphWeaver::Transport::Faraday.new(url) do |conn|
68
89
  conn.request :authorization, "Bearer", -> { Tokens.fetch } # dynamic tokens
69
90
  conn.response :logger
@@ -71,23 +92,83 @@ end
71
92
  GraphWeaver::Transport::Faraday.new(MyApp.faraday_connection)
72
93
 
73
94
  # One Faraday::Connection is reused for the transport's lifetime, but
74
- # socket keep-alive depends on the ADAPTER: Faraday's default net_http
75
- # adapter opens a fresh connection per request. For persistent sockets
76
- # (and real pooling), pick a persistent adapter:
95
+ # socket keep-alive depends on the ADAPTER (see below) — the transport
96
+ # logs the one it ended up with at :info:
77
97
  GraphWeaver::Transport::Faraday.new(url) do |conn|
78
- conn.adapter :net_http_persistent # gem "net-http-persistent"
98
+ conn.adapter :net_http_persistent
79
99
  end
80
100
 
101
+ # In-process: a live graphql-ruby schema class, no socket — typed access
102
+ # to your own app's API. The class alone works in any client slot; the
103
+ # wrapper adds a request context, the same debug logging the network
104
+ # transports emit, and errors branded under GraphWeaver::Error (a resolver
105
+ # raise becomes a ServerError, status 500, with the original as #cause).
106
+ GraphWeaver::InProcess.new(MySchema, context: { current_user: user })
107
+ GraphWeaver.new(MySchema, context: { current_user: user }) # same, via a client
108
+
81
109
  GraphWeaver.client = ... # the app default (a Client or any of the above)
82
110
  ```
83
111
 
112
+ **Keeping Faraday's sockets alive.** Faraday's default `net_http` adapter
113
+ opens a fresh connection per request — 10 TCP connections for 10 requests,
114
+ and over HTTPS a TLS handshake each time. `:net_http_persistent` is the
115
+ adapter that gets Faraday the connection reuse and thread-safe pooling
116
+ `Transport::HTTP` has by default. It needs two gems, and the version
117
+ pairing matters — **Faraday 2.x requires `faraday-net_http_persistent`
118
+ 2.x**; the Faraday-1.x-era 1.2.0 raises `NoMethodError: undefined method
119
+ 'dependency' for class Faraday::Adapter::NetHttpPersistent` at load:
120
+
121
+ ```ruby
122
+ gem "net-http-persistent" # the HTTP client
123
+ gem "faraday-net_http_persistent", "~> 2.0" # the Faraday adapter for it
124
+ ```
125
+
126
+ graph_weaver depends on neither and never selects an adapter for you.
127
+
128
+ **Headers.** Both transports send `Content-Type: application/json`,
129
+ `Accept: application/graphql-response+json, application/json;q=0.9` (the
130
+ media type [GraphQL-over-HTTP](https://graphql.github.io/graphql-over-http/draft/)
131
+ requires a conforming client to accept, with the legacy type as
132
+ fallback), and `User-Agent: graph_weaver/<version>` so a server operator
133
+ can attribute the traffic. Anything you pass in `headers:` wins over
134
+ these. A prebuilt `Faraday::Connection` owns its own headers; only the
135
+ ones it leaves unset are filled in.
136
+
137
+ **Request body.** `{"query": ..., "variables": ...}`, plus
138
+ `"operationName"` when the operation has a name — the field Apollo Studio,
139
+ Hasura and most APMs key traces, rate limits and slow-query reports on.
140
+ Generated modules always send one — an anonymous document is named after its
141
+ module at generation, so the name is declared in the query too. A raw query
142
+ string handed straight to a transport falls back to the name in the document,
143
+ and a genuinely anonymous one sends no `operationName` key at all.
144
+
145
+ **Concurrency.** One transport is normally the whole app's transport
146
+ (`GraphWeaver.client = api`), so it has to serve every thread.
147
+ `Transport::HTTP` opens up to `pool_size:` sockets lazily and reuses the
148
+ warmest one; requests beyond that queue for a free slot rather than
149
+ opening unbounded connections. A socket that errors is closed and its slot
150
+ left empty, so the next call reconnects.
151
+
152
+ `pool_size:` defaults to `RAILS_MAX_THREADS` (else 5) — the same variable
153
+ Rails sizes its own connection pool from, because it is the same question:
154
+ how many requests this process can have in flight at once. Lower it for a
155
+ server that counts connections.
156
+
157
+ Under a fiber scheduler (`async`, Falcon) everything here works unchanged —
158
+ `SizedQueue`, `Mutex`, `net/http` and `Kernel#sleep` are all scheduler-aware,
159
+ so requests multiplex on one thread at thread-equivalent throughput. But
160
+ `pool_size:` is the same hard ceiling there, and nothing sets
161
+ `RAILS_MAX_THREADS` for you, so set it to the concurrency you expect.
162
+ Saturation is not silent: the first request that has to queue logs a warning
163
+ naming how long it waited and what to raise.
164
+
84
165
  ## Client resolution
85
166
 
86
167
  The canonical order — how a generated module finds its client (each slot
87
168
  takes a `Client` or any bare transport/fake):
88
169
 
89
- 1. per call: `execute(some_client, ...)` — the optional first positional
90
- argument, so variables keep the entire kwarg namespace
170
+ 1. per call: `execute(client: some_client, ...)` — a kwarg like the
171
+ variables, and a name no GraphQL variable is allowed to take
91
172
  2. per module: `MyQuery.client = something`
92
173
  3. baked constant: `Codegen.generate(..., client: MyApi::CLIENT)`
93
174
  4. the app default: `GraphWeaver.client=`
@@ -112,11 +193,29 @@ GraphWeaver::Retry.new(
112
193
  ```
113
194
 
114
195
  Defaults: transport failures always retry (the request never arrived);
115
- `ServerError` only on 5xx — a 4xx is a bug in the request, retrying won't
116
- fix it. `retry_codes:` re-inspects response envelopes so GraphQL-level
117
- throttling can retry too (off by default — pass the codes your API uses).
118
- Exhausting `tries:` re-raises the last error (or returns the last
119
- code-matched response).
196
+ `ServerError` on 5xx plus **408 and 429** the rest of 4xx is a bug in
197
+ the request, retrying won't fix it. `retry_codes:` re-inspects response
198
+ envelopes so GraphQL-level throttling can retry too (off by default —
199
+ pass the codes your API uses). Exhausting `tries:` re-raises the last
200
+ error (or returns the last code-matched response).
201
+
202
+ **`Retry-After` wins over the backoff.** When the server names a delay
203
+ (seconds or an HTTP-date), that's the wait — the server is the only
204
+ party that knows when its window reopens. It's clamped to `max:` so a
205
+ "come back in an hour" can't park a thread for an hour, and not
206
+ jittered, since it's an instruction rather than a guess.
207
+
208
+ `ServerError` carries the response `#headers` (names downcased), so the
209
+ rate-limit budget and request id are in hand without monkey-patching a
210
+ transport:
211
+
212
+ ```ruby
213
+ rescue GraphWeaver::ServerError => e
214
+ e.throttled? # 429, or 503 + Retry-After
215
+ e.retry_after # seconds, or nil
216
+ e.headers["x-ratelimit-remaining"]
217
+ end
218
+ ```
120
219
 
121
220
  Or via the client: `GraphWeaver.new(url, retries: { tries: 5, retry_codes: ["THROTTLED"] })`.
122
221
 
data/docs/upgrading.md ADDED
@@ -0,0 +1,112 @@
1
+ # Upgrading to 0.5.0
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.
5
+
6
+ ```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
10
+ ```
11
+
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.
14
+
15
+ ## `execute` means one thing now
16
+
17
+ Every client answers the same call — `execute(query, variables:, operation_name:)`,
18
+ returning the raw response hash. `Client` used to spell something else under
19
+ that name, which is why `Retry.new(client)` and `Sequence.new(client, fake)`
20
+ raised `ArgumentError`. They work now.
21
+
22
+ The one-shot sugar moved to `run`:
23
+
24
+ ```ruby
25
+ client.execute!("query { … }", id: "1") # before
26
+ client.run!("query { … }", id: "1") # after (and #run for the envelope)
27
+
28
+ GraphWeaver.execute(source, query, **vars) # before
29
+ GraphWeaver.run(source, query, **vars) # after
30
+ ```
31
+
32
+ **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`
34
+ and check each hit is passing `variables:` rather than loose kwargs.
35
+
36
+ A generated module takes its per-call client as a **keyword**:
37
+
38
+ ```ruby
39
+ PersonQuery.execute(some_client, id: "1") # before
40
+ PersonQuery.execute(client: some_client, id: "1") # after
41
+ ```
42
+
43
+ `GraphWeaver.resolve_transport` is gone; nothing needs unwrapping any more.
44
+
45
+ ## Path settings are lists
46
+
47
+ `queries_paths`, `generated_paths`, `fragments_paths` — every entry is read.
48
+ Assigning a String still works, so the change is the name:
49
+
50
+ ```ruby
51
+ GraphWeaver.queries_path = "app/graphql/queries" # before
52
+ GraphWeaver.queries_paths = "app/graphql/queries" # after
53
+ ```
54
+
55
+ `schema_path` stays singular: one run reads one schema.
56
+
57
+ ## One reset
58
+
59
+ `GraphWeaver.reset_registrations!` is the clean slate between tests. The four
60
+ narrow ones moved to where they live:
61
+
62
+ ```ruby
63
+ GraphWeaver.reset_scalars! # before
64
+ GraphWeaver::Codegen.reset_scalars! # after (also reset_enums!, clear_scalars!,
65
+ # reset_type_helpers!)
66
+ ```
67
+
68
+ ## Smaller renames
69
+
70
+ | before | after |
71
+ |---|---|
72
+ | `response.ok?` | `response.success?` |
73
+ | `Testing.config.auto_fake = true` | `Testing.config.default_mode = :fake` |
74
+ | `register_scalar(…, coerce: :to_s)` | `coerce: true`, or a `cast:`/`serialize:` pair |
75
+ | a mutation's `…Query` module | `…Mutation` |
76
+
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:
79
+
80
+ ```ruby
81
+ GraphWeaver::Testing.config.schema = MySchema
82
+ ```
83
+
84
+ ## Registering from Rails
85
+
86
+ A registration naming one of your own constants belongs in a `to_prepare` block
87
+ — the same place the in-process client goes, and for the same reason:
88
+ autoloading is set up after `config/initializers` run.
89
+
90
+ ```ruby
91
+ Rails.application.config.to_prepare do
92
+ GraphWeaver.register_enum("Species", PetKind, fallback: PetKind::Unknown)
93
+ GraphWeaver.extend_type("Pet", PetHelpers)
94
+ end
95
+ ```
96
+
97
+ Generation depends on `:environment`, which runs `to_prepare` too, so the
98
+ registration is in place before it emits.
99
+
100
+ ## If you use the federation router
101
+
102
+ Detection only sees *loaded* schema classes, and Rails does not eager load for
103
+ rake or in the default test environment. Both are one line:
104
+
105
+ ```ruby
106
+ config.eager_load = true # config/environments/test.rb
107
+ config.rake_eager_load = true # config/application.rb
108
+ ```
109
+
110
+ Without them the `federation:*` tasks silently see nothing — and
111
+ `federation:diff` now **fails** rather than reporting a green "matches" over
112
+ zero subgraphs.
data/graph_weaver.gemspec CHANGED
@@ -2,7 +2,9 @@ require_relative "lib/graph_weaver/version"
2
2
 
3
3
  Gem::Specification.new do |s|
4
4
  s.authors = ["Daniel Pepper"]
5
- s.description = "A typed GraphQL client for Ruby generate Sorbet T::Structs from queries, with federation, extensibility, and testing in mind"
5
+ # the README tagline, verbatimthe two pitches drifted apart once already,
6
+ # so spec/gemspec_spec.rb pins them together
7
+ s.description = "Your .graphql files, compiled into Sorbet types — and the fakes to test them."
6
8
  # ".yardopts" explicitly: `git ls-files *` skips dotfiles, and
7
9
  # rubydoc.info needs it shipped to render docstrings as markdown
8
10
  s.files = `git ls-files * ':!:spec' ':!:sorbet' ':!:bin' ':!:examples'`.split("\n") + [".yardopts"]
@@ -0,0 +1,259 @@
1
+ # typed: ignore — Rails::Generators DSL, only loaded by `rails g`
2
+ # frozen_string_literal: true
3
+
4
+ require "graph_weaver"
5
+
6
+ # rails g graph_weaver:install https://api.example.com/graphql
7
+ # rails g graph_weaver:install MyApp::Schema
8
+ # rails g graph_weaver:install db/schema.graphql
9
+ #
10
+ # Scaffolds the conventional layout — initializer, query/generated
11
+ # directories, editor config — and bootstraps the schema dump, so the
12
+ # setup in docs/getting_started.md is one command.
13
+ #
14
+ # The argument is what you'd pass to GraphWeaver.new, and the same three
15
+ # source forms are accepted; the initializer it writes reflects the one
16
+ # you chose. The source arrives on the command line rather than being read
17
+ # from config: at install time the initializer doesn't exist yet.
18
+ module GraphWeaver
19
+ module Generators
20
+ class InstallGenerator < Rails::Generators::Base
21
+ desc <<~TEXT
22
+ Wire up GraphWeaver: initializer, app/graphql layout, editor config, schema dump.
23
+
24
+ SOURCE is what you'd pass to GraphWeaver.new:
25
+
26
+ rails g graph_weaver:install https://api.example.com/graphql # an endpoint
27
+ rails g graph_weaver:install MyApp::Schema # a graphql-ruby schema, in-process
28
+ rails g graph_weaver:install db/schema.graphql # a schema dump you already have
29
+ TEXT
30
+
31
+ argument :source, type: :string, banner: "SOURCE",
32
+ desc: "what you'd pass to GraphWeaver.new: an endpoint url, a graphql-ruby schema class, or a schema dump path"
33
+
34
+ class_option :auth, type: :string,
35
+ desc: "name of the ENV var holding the auth token (url only) — default GRAPHWEAVER_AUTH"
36
+ class_option :schema, type: :boolean, default: true,
37
+ desc: "write the schema dump codegen reads"
38
+
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
+ # Before anything is written: a mistyped source or a flag that doesn't
46
+ # apply to it is a mistake in the command just typed, so say so there
47
+ # rather than at boot, three files later.
48
+ def check_source
49
+ if options[:auth] && form != :url
50
+ raise Thor::Error, "--auth applies to a url — #{source} is a #{form == :schema_class ? "schema class" : "schema dump"}"
51
+ end
52
+
53
+ schema_class if form == :schema_class
54
+ end
55
+
56
+ # Every write goes through create_file, so a re-run prompts with a
57
+ # diff rather than overwriting an initializer you've edited.
58
+ def create_initializer
59
+ create_file "config/initializers/graph_weaver.rb", initializer
60
+ end
61
+
62
+ def create_layout
63
+ create_file File.join(GraphWeaver.queries_paths.first, ".keep"), ""
64
+ create_file File.join(GraphWeaver.generated_paths.first, ".keep"), ""
65
+ end
66
+
67
+ # editor autocomplete + validation for .graphql files (docs/editors.md)
68
+ def create_editor_config
69
+ create_file "graphql.config.yml", editor_config
70
+ end
71
+
72
+ # A url is introspected and a schema class dumped; a dump the app
73
+ # already has is left where it is (schema_path points at it instead).
74
+ def fetch_schema
75
+ return unless options[:schema] && form != :path
76
+
77
+ if form == :url
78
+ # pass the var name, not just the token — it lands in the dump's
79
+ # provenance so schema:refresh/:diff read the same one the
80
+ # initializer does, instead of defaulting to GRAPHWEAVER_AUTH
81
+ GraphWeaver::SchemaLoader.refresh!(url: source, auth_env: auth_var)
82
+ else
83
+ # a schema class is its own introspection source; ttl: 0 so an
84
+ # existing dump never counts as fresh
85
+ GraphWeaver::SchemaLoader.introspect(schema_class, cache: schema_path, ttl: 0)
86
+ end
87
+ say_status :introspect, "#{schema_path} from #{source}"
88
+ rescue StandardError => e
89
+ # the files above are the valuable part — don't lose them to a bad
90
+ # token or an unreachable host
91
+ say_status :failed, "#{e.message} — retry with `#{refresh_command}`", :red
92
+ end
93
+
94
+ def next_steps
95
+ say <<~TEXT
96
+
97
+ Write a query in #{GraphWeaver.queries_paths.first}, then:
98
+
99
+ rake graph_weaver:generate
100
+
101
+ Docs: https://github.com/dpep/graph_weaver/blob/main/docs/getting_started.md
102
+ TEXT
103
+
104
+ say federated_steps if subgraphs
105
+ end
106
+
107
+ private
108
+
109
+ # This install run is the one moment the user is guaranteed to be
110
+ # reading, and a composed supergraph changes what the next steps are:
111
+ # the test client is the interesting one, and there's a CI gate to add.
112
+ def federated_steps
113
+ <<~TEXT
114
+
115
+ #{source} is a composed supergraph (#{subgraphs.size} subgraphs: #{subgraphs.join(", ")}), so:
116
+
117
+ rake graph_weaver:federation:diff # CI gate: a subgraph changed, nobody recomposed
118
+ rake graph_weaver:federation:subgraphs # which schema here serves which subgraph
119
+
120
+ and specs run against your real resolvers across all of them, in-process:
121
+
122
+ describe "checkout", graphql: :router do ... end # require "graph_weaver/rspec"
123
+
124
+ Docs: https://github.com/dpep/graph_weaver/blob/main/docs/federation.md
125
+ TEXT
126
+ end
127
+
128
+ # The subgraph names this source composes, or nil when it isn't a
129
+ # composed supergraph. Read off the routing table rather than guessed —
130
+ # the same reader Testing::Router and federation:diff use.
131
+ def subgraphs
132
+ return @subgraphs if defined?(@subgraphs)
133
+
134
+ @subgraphs =
135
+ begin
136
+ (GraphWeaver::SchemaLoader.routing_table(source).subgraphs if form == :path)
137
+ rescue StandardError
138
+ # not a supergraph, or not readable — nothing to say either way
139
+ nil
140
+ end
141
+ end
142
+
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.
146
+ def form
147
+ @form ||=
148
+ if source.match?(GraphWeaver::Client::URL)
149
+ :url
150
+ elsif source.match?(CONSTANT)
151
+ :schema_class
152
+ else
153
+ :path
154
+ end
155
+ end
156
+
157
+ # The named schema class, resolved now: `rails g` boots the app, so a
158
+ # typo is catchable here rather than as a NameError at the next boot.
159
+ def schema_class
160
+ @schema_class ||= begin
161
+ klass = Object.const_get(source)
162
+ unless klass.respond_to?(:execute)
163
+ raise Thor::Error, "#{source} isn't a graphql-ruby schema (no .execute) — pass the class that inherits GraphQL::Schema"
164
+ end
165
+
166
+ klass
167
+ rescue NameError
168
+ raise Thor::Error, "uninitialized constant #{source} — pass your graphql-ruby schema class " \
169
+ "(rails g graphql:install writes app/graphql/<app>_schema.rb), an endpoint url, or a path to a schema dump"
170
+ end
171
+ end
172
+
173
+ # A dump the app already has stays where it is; every other form
174
+ # writes the conventional one.
175
+ def schema_path = (form == :path) ? source : GraphWeaver.schema_path
176
+
177
+ def auth_var = options[:auth] || DEFAULT_AUTH
178
+
179
+ def refresh_command
180
+ (form == :url) ? "rake graph_weaver:schema:refresh" : "rails g graph_weaver:install #{source}"
181
+ end
182
+
183
+ def initializer
184
+ <<~RUBY
185
+ # frozen_string_literal: true
186
+
187
+ #{client_setup}
188
+ # Custom scalars, enums and type mixins go here — `rake graph_weaver:generate`
189
+ # bakes them into the generated source, so they must be registered first:
190
+ #
191
+ # GraphWeaver.register_scalar("DateTime", Time, serialize: :iso8601, requires: "time")
192
+ # GraphWeaver.extend_type("Person", Greetable)
193
+ RUBY
194
+ end
195
+
196
+ # The load-bearing lines, per source form: what generated modules
197
+ # resolve to at execute time, and where codegen reads the schema.
198
+ def client_setup
199
+ case form
200
+ when :url
201
+ <<~RUBY
202
+ GraphWeaver.client = GraphWeaver.new(
203
+ "#{source}",
204
+ auth: ENV["#{auth_var}"],
205
+ cache: true, # reuse the committed dump; delete it to re-introspect
206
+ )
207
+ RUBY
208
+ when :schema_class
209
+ # to_prepare, not a bare assignment: the schema class is autoloaded,
210
+ # so it isn't resolvable this early, and a dev reload replaces it
211
+ # with a new class object the client would otherwise still hold.
212
+ <<~RUBY
213
+ Rails.application.config.to_prepare do
214
+ # queries run in-process against the app's own schema — no socket
215
+ GraphWeaver.client = GraphWeaver.new(#{source})
216
+ end
217
+ RUBY
218
+ when :path
219
+ if subgraphs
220
+ <<~RUBY
221
+ GraphWeaver.schema_path = "#{source}"
222
+
223
+ # A composed supergraph: your queries are generated against the whole
224
+ # graph, and a router serves it. Point the app default at the gateway:
225
+ #
226
+ # GraphWeaver.client = GraphWeaver.new("https://gateway.example.com/graphql")
227
+ #
228
+ # Specs don't need one — `graphql: :router` plans against this
229
+ # supergraph and runs your own subgraph resolvers in-process
230
+ # (docs/federation.md).
231
+ RUBY
232
+ else
233
+ <<~RUBY
234
+ GraphWeaver.schema_path = "#{source}"
235
+
236
+ # A dump is type information only — it has no resolvers, so it can't
237
+ # execute. Point the app default at whatever serves this API:
238
+ #
239
+ # GraphWeaver.client = GraphWeaver.new("https://api.example.com/graphql")
240
+ RUBY
241
+ end
242
+ end
243
+ end
244
+
245
+ # fragments are in documents: too — without them an editor reports
246
+ # `Unknown fragment` on any query that spreads a shared one
247
+ def editor_config
248
+ <<~YAML
249
+ # Autocomplete and validation for .graphql files in VS Code / RubyMine.
250
+ # https://github.com/dpep/graph_weaver/blob/main/docs/editors.md
251
+ schema: #{schema_path}
252
+ documents:
253
+ - #{GraphWeaver.queries_paths.first}/**/*.{graphql,gql}
254
+ - #{GraphWeaver.fragments_paths.first}/**/*.{graphql,gql}
255
+ YAML
256
+ end
257
+ end
258
+ end
259
+ end