graph_weaver 0.1.0 → 0.2.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +213 -0
  4. data/Gemfile.lock +32 -2
  5. data/Makefile +7 -2
  6. data/NOTES.md +1 -1
  7. data/PLAN.md +34 -1
  8. data/README.md +56 -41
  9. data/docs/cassettes.md +76 -0
  10. data/docs/errors.md +37 -9
  11. data/docs/generated_modules.md +139 -29
  12. data/docs/getting_started.md +134 -0
  13. data/docs/logging.md +33 -0
  14. data/docs/real_world.md +27 -20
  15. data/docs/scalars.md +122 -8
  16. data/docs/testing.md +44 -34
  17. data/docs/transports.md +124 -0
  18. data/graph_weaver.gemspec +7 -1
  19. data/lib/graph_weaver/client.rb +200 -0
  20. data/lib/graph_weaver/codegen/emit.rb +88 -39
  21. data/lib/graph_weaver/codegen/enum_type.rb +149 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +109 -9
  23. data/lib/graph_weaver/codegen/scalar_type.rb +53 -35
  24. data/lib/graph_weaver/codegen.rb +301 -67
  25. data/lib/graph_weaver/errors.rb +37 -25
  26. data/lib/graph_weaver/hints.rb +63 -0
  27. data/lib/graph_weaver/inflect.rb +2 -1
  28. data/lib/graph_weaver/logging.rb +41 -0
  29. data/lib/graph_weaver/railtie.rb +29 -0
  30. data/lib/graph_weaver/retry.rb +97 -0
  31. data/lib/graph_weaver/rspec.rb +19 -14
  32. data/lib/graph_weaver/schema_loader.rb +156 -20
  33. data/lib/graph_weaver/selection.rb +3 -3
  34. data/lib/graph_weaver/tasks.rb +71 -0
  35. data/lib/graph_weaver/testing/cassette.rb +42 -21
  36. data/lib/graph_weaver/testing/failure.rb +22 -22
  37. data/lib/graph_weaver/testing/{fake_executor.rb → fake_client.rb} +13 -13
  38. data/lib/graph_weaver/testing/values.rb +2 -2
  39. data/lib/graph_weaver/testing.rb +32 -16
  40. data/lib/graph_weaver/transport/faraday.rb +56 -0
  41. data/lib/graph_weaver/transport/http.rb +87 -0
  42. data/lib/graph_weaver/transport.rb +120 -0
  43. data/lib/graph_weaver/version.rb +1 -1
  44. data/lib/graph_weaver.rb +208 -38
  45. metadata +73 -5
  46. data/lib/graph_weaver/faraday_executor.rb +0 -61
  47. data/lib/graph_weaver/http_executor.rb +0 -44
  48. data/lib/graph_weaver/testing/rspec.rb +0 -5
data/docs/scalars.md CHANGED
@@ -6,9 +6,14 @@ Ruby object (and serializes back when used as a variable). A field typed
6
6
  `Money.parse(...)` inline — no runtime reflection:
7
7
 
8
8
  ```ruby
9
- GraphWeaver.register_scalar("Money", type: Money, requires: "bigdecimal")
9
+ GraphWeaver.register_scalar("Money", Money, requires: "bigdecimal")
10
10
  ```
11
11
 
12
+ Registrations are global by default. A [client](transports.md) scopes
13
+ them: `client.register_scalar(...)` overlays the global registry for that
14
+ client's generation only — so two servers can disagree about what a
15
+ `DateTime` is, and neither leaks into the other.
16
+
12
17
  Pass a real class as `type:` and the cast/serialize are **inferred** from it by
13
18
  probing the deserialize side and pairing its serializer:
14
19
 
@@ -20,7 +25,8 @@ probing the deserialize side and pairing its serializer:
20
25
  so the common case needs nothing more. Probing the *deserialize* side is
21
26
  deliberate — every object has `#to_s`, so inferring off it would wrongly wrap
22
27
  plain types like `String`/`Integer`; requiring a `.parse`/`.load` the type
23
- actually defines avoids that (and is why the built-ins can be registered with
28
+ actually defines avoids that (and is why the built-in scalars `Date`, `ID`,
29
+ `Int`, and friends, pre-registered and detailed below — can be registered with
24
30
  their real class constants). Override explicitly when you need to:
25
31
 
26
32
  - a `Symbol` method name, nothing to misspell: `cast: :load` → `Money.load(expr)`,
@@ -38,7 +44,7 @@ Pass `coerce: true` to let a variable of this scalar accept **either** the value
38
44
  object **or** its raw input, normalizing the latter through the cast:
39
45
 
40
46
  ```ruby
41
- GraphWeaver.register_scalar("Money", type: Money, coerce: true)
47
+ GraphWeaver.register_scalar("Money", Money, coerce: true)
42
48
  # generated execute now takes T.any(Money, String); "12.00" is parsed
43
49
  StoreQuery.execute(budget: "12.00") # Money.parse("12.00") under the hood
44
50
  StoreQuery.execute(budget: Money.new(1200)) # passed straight through
@@ -51,15 +57,17 @@ needs both a cast and a serialize. Off by default — the strict typed kwarg is
51
57
  a plain method is the whole story — `coerce: :to_f` makes a variable accept
52
58
  `5`/`"5"` and `.to_f` it, sending a native number (not `"5.0"`) on the wire. The
53
59
  convertible built-ins already know theirs (`Float`→`:to_f`, `Int`→`:to_i`,
54
- `ID`/`String`→`:to_s`), so rather than re-registering each, flip them all on at
55
- once:
60
+ `ID`/`String`→`:to_s`), so rather than opting in each, flip the default:
56
61
 
57
62
  ```ruby
58
- GraphWeaver.reset_scalars!(coerce: true) # reload built-ins as coercible
59
- GraphWeaver.register_scalar("Money", ...) # then add your own
63
+ GraphWeaver.auto_coerce = true
60
64
  ```
61
65
 
62
- `Boolean` and `Date` have no lossless one-method conversion, so they stay strict.
66
+ Resolved lazily at generation time (set it any time before you generate),
67
+ it gives convertible built-ins their conversion and any scalar with a full
68
+ cast/serialize pair (`Date`, your `Money`) parse-style coercion; an explicit
69
+ `coerce:` on a registration always wins. `Boolean` has no lossless
70
+ one-method conversion, so it stays strict.
63
71
 
64
72
  The built-in scalars (`Date`, `ID`, `Int`, …) are pre-registered through the
65
73
  same path (`Date` even carries its own `require "date"`), so a later
@@ -67,3 +75,109 @@ same path (`Date` even carries its own `require "date"`), so a later
67
75
  defaults (`reset_scalars!(coerce: true)` restores them coercible) and
68
76
  `clear_scalars!` empties the registry. Register before generating — it's a
69
77
  codegen-time concern, baked into the emitted source.
78
+
79
+ ## Enums: map onto your own T::Enum
80
+
81
+ By default each generated module grows its own `T::Enum` per GraphQL
82
+ enum — `AddPetQuery::Species`, `SearchQuery::Result::...::Species`, one
83
+ per module that touches it. That's fine until your app has its own
84
+ domain enum, and then the boundary shuffle starts:
85
+
86
+ ```ruby
87
+ # your domain already speaks PetKind — it's in your models, your
88
+ # ActiveRecord enum column, your case statements
89
+ class PetKind < T::Enum
90
+ enums { Cat = new("cat"); Dog = new("dog") }
91
+ end
92
+
93
+ # without a mapping, every call site converts by hand, in both directions
94
+ kind = PetKind.deserialize(pet.species.serialize.downcase) # response -> domain
95
+ AddPetQuery.execute!(species: kind.serialize.upcase) # domain -> wire
96
+ ```
97
+
98
+ Two enums for one concept, glue at every crossing, and each generated
99
+ module has its *own* incompatible `Species`, so a pet from `SearchQuery`
100
+ and a pet from `AddPetQuery` don't even compare. Register the mapping
101
+ once and the seam disappears — generated code speaks your enum
102
+ everywhere, casting wire values in and serializing members out:
103
+
104
+ ```ruby
105
+ GraphWeaver.register_enum("Species", PetKind) # global
106
+ api.register_enums("Species" => PetKind, "Role" => Role) # or per client, in bulk
107
+
108
+ pet.species # => PetKind::Dog — compare, case, persist directly
109
+ pet.species == other_pet.species # same type across every query
110
+ AddPetQuery.execute!(species: PetKind::Cat) # or "CAT" — members and wire values both work
111
+ ```
112
+
113
+ **When to reach for it**: the enum has a life outside the API — it's
114
+ persisted, matched in business logic, or shared across queries. **When
115
+ not to bother**: display-only values you read and forget; the per-module
116
+ generated enums are self-contained and need zero setup.
117
+
118
+ The mapping is inferred by name (`"CAT"` ↔ `PetKind::Cat`,
119
+ case/underscore-insensitive against each member's serialized value), so
120
+ aligned enums need only the one line. When names diverge, `map:` pins the
121
+ exceptions and merges over inference:
122
+
123
+ ```ruby
124
+ GraphWeaver.register_enum("Species", PetKind, map: { "FELINE" => PetKind::Cat })
125
+ ```
126
+
127
+ Two safety properties do the real work:
128
+
129
+ - **Exhaustiveness at generation**: every value the schema declares must
130
+ resolve to a member, or generation fails naming the gaps
131
+ (`PetKind has no member for Species value(s) DOG — add them, pin with
132
+ map:, or absorb with fallback:`). Your enum drifting from the server's
133
+ is caught at `rake graph_weaver:generate`, not in production.
134
+ - **`fallback:` for forward-compat**: `fallback: PetKind::Unknown` makes
135
+ *casting* absorb wire values the server added after you generated —
136
+ responses keep flowing instead of raising. Inputs stay strict either
137
+ way: a typo'd input is your bug, not drift.
138
+
139
+ The translation tables are emitted into the generated source
140
+ (`SPECIES_FROM_WIRE` / `SPECIES_TO_WIRE`) — reviewable in the diff, no
141
+ runtime registry. And because registration can be client-scoped, two
142
+ servers with different ideas of `"Species"` can map onto different (or
143
+ the same) domain enums without touching each other.
144
+
145
+ ## Type helpers: your logic on generated structs
146
+
147
+ Derived values (display names, emoji, predicates) belong next to the
148
+ data but not *in* it — rewriting wire values on the way in destroys the
149
+ raw truth. Register a plain module and every struct generated from that
150
+ GraphQL type includes it, whatever query it appears in:
151
+
152
+ ```ruby
153
+ module PetHelpers
154
+ def adult? = birthday && birthday < Date.today << 24
155
+ def display_name = adult? ? "#{name} 🦴" : "#{name} 🐶"
156
+ end
157
+
158
+ GraphWeaver.register_type("Pet", PetHelpers) # or api.register_type(...)
159
+
160
+ pet.display_name # => "Shelby 🦴"
161
+ pet.name # => "Shelby" — the wire value stays honest
162
+ ```
163
+
164
+ Because the include is emitted into the generated source, `srb tc` checks
165
+ the helpers against each query's actual selection — a helper that calls
166
+ `birthday` on a query that never selected it is a **static error**, which
167
+ doubles as selection-completeness checking. Registrations are additive
168
+ (global plus client-scoped stack), and fakes/cassettes get the behavior
169
+ automatically since it lives on the struct.
170
+
171
+ For quick decoration, build the mixin inline — the block is
172
+ `module_eval`'d into a fresh module auto-named under
173
+ `GraphWeaver::TypeHelpers` so generated files can reference it:
174
+
175
+ ```ruby
176
+ api.register_type("Pet") do
177
+ def display_name = "#{name} 🐶"
178
+ end
179
+ ```
180
+
181
+ Same runtime behavior, one caveat: `srb tc` can't see into block-defined
182
+ methods, so prefer a named module where static checking matters —
183
+ complexity on demand.
data/docs/testing.md CHANGED
@@ -1,16 +1,22 @@
1
1
  # Testing
2
2
 
3
+ Everything here is a *client* — the one interface queries run
4
+ through: anything with `execute(query, variables:)` returning
5
+ `{"data" => ..., "errors" => ...}` (see [transports](transports.md)).
6
+ Fakes, failures, and cassettes all slot in wherever a real transport
7
+ would.
8
+
3
9
  `require "graph_weaver/rspec"` from your spec helper (or
4
- `graph_weaver/testing` outside rspec — never production code) for a
5
- zero-setup fake backend. `FakeExecutor` fabricates
10
+ `graph_weaver/testing` outside rspec — never in production) for a
11
+ zero-setup fake backend. `FakeClient` fabricates
6
12
  schema-correct responses for whatever query arrives: real enum values,
7
13
  valid `__typename` members, iso8601 date scalars — every fake casts
8
14
  cleanly through your generated structs.
9
15
 
10
16
  ```ruby
11
- fake = GraphWeaver::Testing::FakeExecutor.new(schema:)
17
+ fake = GraphWeaver::Testing::FakeClient.new(schema:)
12
18
 
13
- person = PersonQuery.execute!(id: "1", executor: fake).person
19
+ person = PersonQuery.execute!(fake, id: "1").person
14
20
  person.name # => "Eliza Kertzmann" (faker-matched on field name, when faker is loaded)
15
21
  person.birthday # => a real Date
16
22
  ```
@@ -19,20 +25,26 @@ Pin what matters, keyed by GraphQL names (schema vocabulary — keys
19
25
  survive query refactors); `"Type.field"` beats `"field"`:
20
26
 
21
27
  ```ruby
22
- GraphWeaver::Testing::FakeExecutor.new(schema:, overrides: {
28
+ GraphWeaver::Testing::FakeClient.new(schema:, overrides: {
23
29
  "Person.name" => "Daniel",
24
30
  "email" => -> { "test@example.com" },
25
31
  })
26
32
  ```
27
33
 
28
- Or configure once, initializer-style (e.g. in `spec/support/graph_weaver.rb`):
34
+ With rspec, one line in `spec/support/graph_weaver.rb` is the whole
35
+ default setup — the schema auto-locates from the committed dump at
36
+ `GraphWeaver.schema_path`, and `auto_fake` defaults on:
29
37
 
30
38
  ```ruby
31
- require "graph_weaver/rspec" # rspec: seed follows --seed
39
+ require "graph_weaver/rspec" # seed follows --seed; every example auto-fakes
40
+ ```
32
41
 
42
+ Configure to override any of it:
43
+
44
+ ```ruby
33
45
  GraphWeaver::Testing.configure do |config|
34
- config.schema = MySchema
35
- config.auto_fake = true # every example runs against a fresh FakeExecutor
46
+ config.schema = MySchema # an in-process class instead of the located dump
47
+ config.auto_fake = false # opt out of per-example fakes
36
48
  config.mode = :faker # or :literal (plain typed values); nil = auto
37
49
  config.overrides = { "Person.name" => "Daniel" }
38
50
  config.list_size = 1..3
@@ -41,53 +53,51 @@ end
41
53
  ```
42
54
 
43
55
  With the rspec integration, `rspec --seed 1234` reproduces fake data
44
- along with test order, and `auto_fake` installs a seeded executor per
45
- example (generate modules *without* a baked `executor:` so they consult
46
- `GraphWeaver.executor`). `mode:` picks value fabrication: `:faker`
56
+ along with test order, and `auto_fake` installs a seeded fake as the
57
+ app client per example (generate modules *without* a baked `client:` so
58
+ they consult `GraphWeaver.client`). `mode:` picks value fabrication: `:faker`
47
59
  (semantic, field-name matched — raises if the gem is missing),
48
60
  `:literal` (plain type-derived), or nil to auto-detect faker.
49
61
 
50
- **Simulating failures** — every failure mode is just an executor, so
62
+ **Simulating failures** — every failure mode is just a client, so
51
63
  error-handling paths are testable without a server that misbehaves on cue:
52
64
 
53
65
  ```ruby
54
66
  Failure = GraphWeaver::Testing::Failure
55
67
 
56
- PersonQuery.execute(id: "1", executor: Failure.transport) # TransportError (cause preserved)
57
- PersonQuery.execute(id: "1", executor: Failure.server(status: 502)) # ServerError
58
- PersonQuery.execute(id: "1", executor: Failure.throttled) # QueryError, code THROTTLED
59
- PersonQuery.execute(id: "1", executor: Failure.stale_schema) # schema_stale? => true
60
- PersonQuery.execute(id: "1", executor: Failure.graphql("boom", data: {...})) # partial failure
68
+ PersonQuery.execute(id: "1", client: Failure.transport) # TransportError (cause preserved)
69
+ PersonQuery.execute(id: "1", client: Failure.server(status: 502)) # ServerError
70
+ PersonQuery.execute(id: "1", client: Failure.throttled) # QueryError, code THROTTLED
71
+ PersonQuery.execute(id: "1", client: Failure.stale_schema) # schema_stale? => true
72
+ PersonQuery.execute(id: "1", client: Failure.graphql("boom", data: {...})) # partial failure
61
73
 
62
- # retries: fail twice, then succeed
63
- GraphWeaver::Testing::SequenceExecutor.new(Failure.transport, Failure.transport, fake)
74
+ # retries: clients run in sequence (the last repeats) — here, two
75
+ # transport failures and then a FakeClient serving good responses
76
+ fake = GraphWeaver::Testing::FakeClient.new(schema:)
77
+ GraphWeaver::Testing::Sequence.new(Failure.transport, Failure.transport, fake)
64
78
 
65
79
  # type mismatch: corrupt: derives a wrong-typed wire value for the field —
66
80
  # casting raises GraphWeaver::TypeError (overrides remain the manual escape hatch)
67
- GraphWeaver::Testing::FakeExecutor.new(schema:, corrupt: "Person.birthday")
81
+ GraphWeaver::Testing::FakeClient.new(schema:, corrupt: "Person.birthday")
68
82
 
69
83
  # stale schema naming a real (sampled) field
70
84
  Failure.stale_schema(schema: MySchema)
71
85
 
72
86
  # field-level partial failure with real GraphQL null propagation: the error
73
87
  # lands with its concrete path and nulls bubble to the nearest nullable spot
74
- GraphWeaver::Testing::FakeExecutor.new(schema:, fail_at: { path: "person.email", code: "PRIVATE" })
88
+ GraphWeaver::Testing::FakeClient.new(schema:, fail_at: { path: "person.email", code: "PRIVATE" })
75
89
  ```
76
90
 
77
- **Capture and replay** — cassettes live above the transport (no HTTP
78
- interception), so they work identically for HTTP, Faraday, or in-process
79
- executors:
91
+ **Capture and replay** — cassettes record real API responses and replay
92
+ them offline, above the transport (no HTTP interception):
80
93
 
81
94
  ```ruby
82
- # records against the live executor when the file is missing, replays after
83
- executor = GraphWeaver::Testing::Cassette.use("github", executor: live)
95
+ # records against the live client when the file is missing, replays after
96
+ cassette = GraphWeaver::Testing::Cassette.use("github", client: live)
84
97
  ```
85
98
 
86
- Cassettes hold real responses. Anonymize before committing — shape,
87
- nulls, list lengths, enums, and id relationships survive; values are
88
- replaced with (semantically matched) fakes:
89
-
90
- ```ruby
91
- GraphWeaver::Testing::Cassette.new("spec/cassettes/github.yml").anonymize!(schema:)
92
- ```
99
+ Re-record with `GRAPHWEAVER_RECORD=1`, anonymize before committing
100
+ (`config.anonymize = true` scrubs as recordings happen, or
101
+ `rake graph_weaver:cassettes:anonymize` after) — the full workflow guide
102
+ is **[cassettes](cassettes.md)**.
93
103
 
@@ -0,0 +1,124 @@
1
+ # Transports
2
+
3
+ A *client* is anything with `execute(query, variables:)` whose result
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.
8
+
9
+ A *transport* is the network end of that contract — GraphQL-over-HTTP. The bundled
10
+ two — `Transport::HTTP` (net/http, zero dependencies, loaded by default)
11
+ and `Transport::Faraday` (opt-in) — subclass `GraphWeaver::Transport`,
12
+ which owns the shared flow: encode the request, reclassify network
13
+ failures as `TransportError`, raise `ServerError` on non-2xx, parse the
14
+ body. A subclass only implements `post(body) => [status, body]` — that's
15
+ the whole recipe for bringing your own HTTP client.
16
+
17
+ ## One-shot setup: a client
18
+
19
+ Most apps need one line:
20
+
21
+ ```ruby
22
+ github = GraphWeaver.new("https://api.example.com/graphql", auth: ENV["API_TOKEN"])
23
+ ```
24
+
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.
28
+
29
+ - `auth:` — a token; "Bearer" is assumed unless the string carries its own
30
+ scheme (`"Basic dXNlcjpwYXNz..."`)
31
+ - `headers:` — anything else (API keys, custom headers)
32
+ - `retries:` — off by default; `true` for a `Retry` with defaults,
33
+ or a Hash of its options
34
+ - `cache:` / `ttl:` — schema introspection caching (see
35
+ [real world](real_world.md)); url clients only — a schema source never
36
+ introspects, so passing them raises
37
+ - a block customizes the Faraday connection (Faraday only — raises without it)
38
+
39
+ To wire generated modules that don't bake a client, make it the app's
40
+ 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.
50
+
51
+ ## Building blocks
52
+
53
+ The client is convenience, not the only door — construct and assign
54
+ yourself for full control:
55
+
56
+ ```ruby
57
+ # zero-dependency Net::HTTP — persistent (keep-alive) connection,
58
+ # mutex-serialized; timeouts raise retriable TransportError
59
+ GraphWeaver::Transport::HTTP.new(
60
+ url,
61
+ headers: { ... },
62
+ open_timeout: 10, read_timeout: 30, # seconds (the defaults)
63
+ keep_alive_timeout: 2, # idle window before reconnecting
64
+ )
65
+
66
+ # Faraday: a url (+ optional middleware block), or a ready connection
67
+ GraphWeaver::Transport::Faraday.new(url) do |conn|
68
+ conn.request :authorization, "Bearer", -> { Tokens.fetch } # dynamic tokens
69
+ conn.response :logger
70
+ end
71
+ GraphWeaver::Transport::Faraday.new(MyApp.faraday_connection)
72
+
73
+ # 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:
77
+ GraphWeaver::Transport::Faraday.new(url) do |conn|
78
+ conn.adapter :net_http_persistent # gem "net-http-persistent"
79
+ end
80
+
81
+ GraphWeaver.client = ... # the app default (a Client or any of the above)
82
+ ```
83
+
84
+ ## Client resolution
85
+
86
+ The canonical order — how a generated module finds its client (each slot
87
+ takes a `Client` or any bare transport/fake):
88
+
89
+ 1. per call: `execute(some_client, ...)` — the optional first positional
90
+ argument, so variables keep the entire kwarg namespace
91
+ 2. per module: `MyQuery.client = something`
92
+ 3. baked constant: `Codegen.generate(..., client: MyApi::CLIENT)`
93
+ 4. the app default: `GraphWeaver.client=`
94
+
95
+ Nothing set anywhere raises with a message saying which knobs exist.
96
+
97
+ ## Retries
98
+
99
+ `Retry` wraps any client/transport:
100
+
101
+ ```ruby
102
+ GraphWeaver::Retry.new(
103
+ inner_transport,
104
+ tries: 5, # total attempts, first included
105
+ backoff: :exponential, # or :linear, or ->(attempt) { seconds }
106
+ base: 0.5, max: 30, # seconds; delays clamp at max:
107
+ jitter: true, # randomize each delay by 50-100%
108
+ on: [GraphWeaver::TransportError, GraphWeaver::ServerError],
109
+ retry_if: ->(error) { ... }, # fine-grain within on:
110
+ retry_codes: ["THROTTLED"], # also retry GraphQL errors by code
111
+ )
112
+ ```
113
+
114
+ 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).
120
+
121
+ Or via the client: `GraphWeaver.new(url, retries: { tries: 5, retry_codes: ["THROTTLED"] })`.
122
+
123
+ What classifies as a transport failure is an extensible set — see
124
+ [errors](errors.md#extending-transporterror) (`GraphWeaver.register_transport_error`).
data/graph_weaver.gemspec CHANGED
@@ -3,7 +3,9 @@ require_relative "lib/graph_weaver/version"
3
3
  Gem::Specification.new do |s|
4
4
  s.authors = ["Daniel Pepper"]
5
5
  s.description = "A typed GraphQL client for Ruby — generate Sorbet T::Structs from queries, with federation, extensibility, and testing in mind"
6
- s.files = `git ls-files * ':!:spec' ':!:sorbet' ':!:bin'`.split("\n")
6
+ # ".yardopts" explicitly: `git ls-files *` skips dotfiles, and
7
+ # rubydoc.info needs it shipped to render docstrings as markdown
8
+ s.files = `git ls-files * ':!:spec' ':!:sorbet' ':!:bin' ':!:examples'`.split("\n") + [".yardopts"]
7
9
  s.homepage = "https://github.com/dpep/graph_weaver"
8
10
  s.license = "MIT"
9
11
  s.name = "graph_weaver"
@@ -15,13 +17,17 @@ Gem::Specification.new do |s|
15
17
  s.add_dependency "graphql", ">= 2"
16
18
  s.add_dependency "sorbet-runtime"
17
19
 
20
+ s.add_development_dependency "apollo-federation" # federation integration subgraphs
18
21
  s.add_development_dependency "bigdecimal"
19
22
  s.add_development_dependency "debug"
20
23
  s.add_development_dependency "faker"
21
24
  s.add_development_dependency "faraday"
25
+ s.add_development_dependency "rake"
26
+ s.add_development_dependency "redcarpet" # yard --markup markdown
22
27
  s.add_development_dependency "rspec"
23
28
  s.add_development_dependency "simplecov"
24
29
  s.add_development_dependency "sorbet"
25
30
  s.add_development_dependency "tapioca"
26
31
  s.add_development_dependency "webrick"
32
+ s.add_development_dependency "yard"
27
33
  end
@@ -0,0 +1,200 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "codegen"
5
+ require_relative "errors"
6
+ require_relative "inflect"
7
+ require_relative "retry"
8
+ require_relative "schema_loader"
9
+ require_relative "transport/http"
10
+
11
+ # One object tying the whole flow together — transport, schema, and
12
+ # generation:
13
+ #
14
+ # github = GraphWeaver.new("https://api.github.com/graphql", auth: token, cache: true)
15
+ # github.register_scalar("DateTime", Time, serialize: :iso8601, requires: "time")
16
+ #
17
+ # RepoQuery = github.parse("queries/repo.graphql") # implicit schema + transport
18
+ # github.execute!("query { viewer { login } }") # one-shot
19
+ #
20
+ # The first argument is a url (a transport is built; the schema comes
21
+ # from introspection on first use, cached per cache:/ttl:) or a schema
22
+ # source — a live schema class (which also executes in-process), or a
23
+ # path/SDL/introspection dump via SchemaLoader. Pass transport: to
24
+ # bring your own transport for a schema source.
25
+ #
26
+ # Clients are independent: each has its own transport, schema, and
27
+ # scalar registrations, so one app can talk to several GraphQL servers —
28
+ # even ones that disagree about what a "DateTime" is.
29
+ class GraphWeaver::Client
30
+ URL = %r{\Ahttps?://}i
31
+
32
+ def initialize(source, auth: nil, headers: {}, retries: false, transport: nil, cache: nil, ttl: nil, &middleware)
33
+ if source.is_a?(String) && source.match?(URL)
34
+ raise ArgumentError, "pass a url or transport:, not both" if transport
35
+
36
+ @transport = wrap_retries(build_transport(source, auth:, headers:, &middleware), retries)
37
+ else
38
+ if auth || middleware || retries != false
39
+ raise ArgumentError, "auth:/retries:/middleware apply to a url — got a schema source"
40
+ end
41
+ if cache || ttl
42
+ # a schema source never introspects, so a cache would silently no-op
43
+ raise ArgumentError, "cache:/ttl: apply to url introspection — got a schema source"
44
+ end
45
+
46
+ # a live schema class doubles as an in-process transport; a loaded
47
+ # dump has no resolvers, so it is type information only
48
+ @schema = source.is_a?(Module) ? source : GraphWeaver::SchemaLoader.load(source)
49
+ @transport = transport || (source if source.is_a?(Module))
50
+ end
51
+
52
+ @cache = cache
53
+ @ttl = ttl
54
+ @scalars = {}
55
+ @enums = {}
56
+ @types = {}
57
+ end
58
+
59
+ # The transport queries run through: a url-built transport, an
60
+ # explicit transport:, or the live schema class executing in-process.
61
+ # Clients are self-contained — the app default never leaks in; nil for
62
+ # schema-dump clients (type information only).
63
+ attr_reader :transport
64
+
65
+ # transport, when this client must be able to execute
66
+ def transport!
67
+ transport or raise GraphWeaver::Error,
68
+ "this client has no transport (built from a schema dump) — pass a url or transport:"
69
+ end
70
+
71
+ # The schema, introspecting through the transport on first use (cached
72
+ # per the client's cache:/ttl:) unless one was given up front.
73
+ def schema
74
+ @schema ||= GraphWeaver::SchemaLoader.introspect(transport!, cache: @cache, ttl: @ttl)
75
+ end
76
+
77
+ # Client-scoped scalar registration: consulted before the global
78
+ # registry when this client generates code, so two clients can map the
79
+ # same scalar name onto different Ruby types. Same signature as
80
+ # GraphWeaver.register_scalar.
81
+ def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
82
+ validate_registration!("scalar", graphql_name.to_s)
83
+ @scalars[graphql_name.to_s] =
84
+ GraphWeaver::Codegen::ScalarType.new(graphql_name, type, cast:, serialize:, requires:, coerce:)
85
+ end
86
+
87
+ # Client-scoped enum mapping: this client's generated code speaks your
88
+ # T::Enum for the named GraphQL enum (see Codegen::EnumType — inference
89
+ # by name, map: for renames, fallback: to absorb unknown wire values).
90
+ def register_enum(graphql_name, type, map: nil, fallback: nil, requires: nil)
91
+ validate_registration!("enum", graphql_name.to_s)
92
+ @enums[graphql_name.to_s] =
93
+ GraphWeaver::Codegen::EnumType.new(graphql_name, type, map:, fallback:, requires:)
94
+ end
95
+
96
+ # Bulk, inference-only form: register_enums("Species" => PetKind, ...)
97
+ def register_enums(mappings)
98
+ mappings.each { |graphql_name, type| register_enum(graphql_name, type) }
99
+ end
100
+
101
+ # Client-scoped type helpers: include app-owned modules into every
102
+ # struct this client generates from the named GraphQL type — pass
103
+ # modules, or a block to build one inline. Additive with global
104
+ # registrations (see GraphWeaver.register_type).
105
+ def register_type(graphql_name, *mixins, requires: nil, &block)
106
+ validate_registration!("type", graphql_name.to_s)
107
+ entry = @types[graphql_name.to_s] ||= { mixins: [], requires: [] }
108
+ GraphWeaver::Codegen.add_type_helpers(entry, graphql_name, mixins, requires, block)
109
+ end
110
+
111
+ # Parse a query (a .graphql path or raw string) into a typed module
112
+ # bound to this client's schema, scalars, enums, helpers, and transport
113
+ # (including a live schema class executing in-process — the module came
114
+ # from this client, so it runs against it; pass a client per call to
115
+ # override, e.g. with a fake).
116
+ def parse(query, name: nil)
117
+ GraphWeaver.parse(schema:, query:, name:, client: transport,
118
+ scalars: @scalars, enums: @enums, types: @types)
119
+ end
120
+
121
+ # Parse every .graphql query in a directory into typed modules, named
122
+ # like generation would name them — the no-build-step analog of
123
+ # generate! + load_generated!:
124
+ #
125
+ # github.load_queries! # queries/person.graphql => ::PersonQuery
126
+ # github.load_queries!(namespace: Github) # => Github::PersonQuery
127
+ #
128
+ # Reloadable (constants are replaced), so it suits consoles and dev.
129
+ # Returns the modules.
130
+ def load_queries!(dir = GraphWeaver.queries_path, namespace: Object)
131
+ Dir[File.join(dir, "*.graphql")].sort.map do |path|
132
+ name = "#{GraphWeaver::Inflect.camelize(File.basename(path, ".graphql"))}Query"
133
+ namespace.send(:remove_const, name) if namespace.const_defined?(name, false)
134
+ GraphWeaver.log(:info) { "loaded #{name} from #{path}" }
135
+ namespace.const_set(name, parse(path))
136
+ end
137
+ end
138
+
139
+ # One-shot dynamic execution — parse + execute, returning the typed
140
+ # Response envelope (execute! returns the result or raises). Variables
141
+ # are plain kwargs, exactly as on a generated module; graphql-cased
142
+ # string keys work too.
143
+ def execute(query, **variables)
144
+ mod = parse(query)
145
+ kwargs = variables.to_h { |key, value| [GraphWeaver::Inflect.underscore(key.to_s).to_sym, value] }
146
+ mod.execute(transport!, **kwargs)
147
+ end
148
+
149
+ def execute!(query, **variables)
150
+ execute(query, **variables).data!
151
+ end
152
+
153
+ private
154
+
155
+ # Fail a typo'd registration at the call site when the schema is
156
+ # already in hand (a schema-source client, or a url client after first
157
+ # use) — immediate feedback in consoles. Lazily-introspecting clients
158
+ # get the same check at generation time instead; never trigger an
159
+ # introspection just to validate a name.
160
+ def validate_registration!(kind, name)
161
+ return unless @schema
162
+
163
+ GraphWeaver::Codegen.validate_registration!(@schema, kind, name)
164
+ end
165
+
166
+ # auth: is a token — "Bearer" is assumed unless the string carries its
167
+ # own scheme ("Basic dXNlcjpwYXNz..."). Transport pick: Faraday when
168
+ # the app already loads it (its middleware/proxy/timeout ecosystem
169
+ # comes along), the zero-dependency Transport::HTTP otherwise.
170
+ # Detection is `defined?(Faraday)` — deliberately NOT a require:
171
+ # faraday rides along transitively in most bundles (stripe, octokit,
172
+ # ...), and try-requiring would switch transports on apps that never
173
+ # chose it. With faraday under `require: false`, load it before
174
+ # building the client.
175
+ def build_transport(url, auth:, headers:, &middleware)
176
+ headers = headers.dup
177
+ if auth
178
+ headers["Authorization"] ||= auth.include?(" ") ? auth : "Bearer #{auth}"
179
+ end
180
+
181
+ if defined?(::Faraday)
182
+ require_relative "transport/faraday"
183
+ GraphWeaver::Transport::Faraday.new(url, headers:, &middleware)
184
+ elsif middleware
185
+ raise ArgumentError, "middleware blocks require the faraday gem"
186
+ else
187
+ GraphWeaver::Transport::HTTP.new(url, headers:)
188
+ end
189
+ end
190
+
191
+ # retries: is off by default — true for Retry defaults, or a
192
+ # Hash of its options
193
+ def wrap_retries(transport, retries)
194
+ case retries
195
+ when true then GraphWeaver::Retry.new(transport)
196
+ when false, nil then transport
197
+ else GraphWeaver::Retry.new(transport, **retries)
198
+ end
199
+ end
200
+ end