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.
- checksums.yaml +4 -4
- data/.yardopts +6 -0
- data/CHANGELOG.md +213 -0
- data/Gemfile.lock +32 -2
- data/Makefile +7 -2
- data/NOTES.md +1 -1
- data/PLAN.md +34 -1
- data/README.md +56 -41
- data/docs/cassettes.md +76 -0
- data/docs/errors.md +37 -9
- data/docs/generated_modules.md +139 -29
- data/docs/getting_started.md +134 -0
- data/docs/logging.md +33 -0
- data/docs/real_world.md +27 -20
- data/docs/scalars.md +122 -8
- data/docs/testing.md +44 -34
- data/docs/transports.md +124 -0
- data/graph_weaver.gemspec +7 -1
- data/lib/graph_weaver/client.rb +200 -0
- data/lib/graph_weaver/codegen/emit.rb +88 -39
- data/lib/graph_weaver/codegen/enum_type.rb +149 -0
- data/lib/graph_weaver/codegen/nodes.rb +109 -9
- data/lib/graph_weaver/codegen/scalar_type.rb +53 -35
- data/lib/graph_weaver/codegen.rb +301 -67
- data/lib/graph_weaver/errors.rb +37 -25
- data/lib/graph_weaver/hints.rb +63 -0
- data/lib/graph_weaver/inflect.rb +2 -1
- data/lib/graph_weaver/logging.rb +41 -0
- data/lib/graph_weaver/railtie.rb +29 -0
- data/lib/graph_weaver/retry.rb +97 -0
- data/lib/graph_weaver/rspec.rb +19 -14
- data/lib/graph_weaver/schema_loader.rb +156 -20
- data/lib/graph_weaver/selection.rb +3 -3
- data/lib/graph_weaver/tasks.rb +71 -0
- data/lib/graph_weaver/testing/cassette.rb +42 -21
- data/lib/graph_weaver/testing/failure.rb +22 -22
- data/lib/graph_weaver/testing/{fake_executor.rb → fake_client.rb} +13 -13
- data/lib/graph_weaver/testing/values.rb +2 -2
- data/lib/graph_weaver/testing.rb +32 -16
- data/lib/graph_weaver/transport/faraday.rb +56 -0
- data/lib/graph_weaver/transport/http.rb +87 -0
- data/lib/graph_weaver/transport.rb +120 -0
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +208 -38
- metadata +73 -5
- data/lib/graph_weaver/faraday_executor.rb +0 -61
- data/lib/graph_weaver/http_executor.rb +0 -44
- 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",
|
|
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-
|
|
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",
|
|
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
|
|
55
|
-
once:
|
|
60
|
+
`ID`/`String`→`:to_s`), so rather than opting in each, flip the default:
|
|
56
61
|
|
|
57
62
|
```ruby
|
|
58
|
-
GraphWeaver.
|
|
59
|
-
GraphWeaver.register_scalar("Money", ...) # then add your own
|
|
63
|
+
GraphWeaver.auto_coerce = true
|
|
60
64
|
```
|
|
61
65
|
|
|
62
|
-
|
|
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
|
|
5
|
-
zero-setup fake backend. `
|
|
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::
|
|
17
|
+
fake = GraphWeaver::Testing::FakeClient.new(schema:)
|
|
12
18
|
|
|
13
|
-
person = PersonQuery.execute!(id: "1"
|
|
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::
|
|
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
|
-
|
|
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" #
|
|
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 =
|
|
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
|
|
45
|
-
example (generate modules *without* a baked `
|
|
46
|
-
`GraphWeaver.
|
|
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
|
|
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",
|
|
57
|
-
PersonQuery.execute(id: "1",
|
|
58
|
-
PersonQuery.execute(id: "1",
|
|
59
|
-
PersonQuery.execute(id: "1",
|
|
60
|
-
PersonQuery.execute(id: "1",
|
|
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:
|
|
63
|
-
|
|
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::
|
|
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::
|
|
88
|
+
GraphWeaver::Testing::FakeClient.new(schema:, fail_at: { path: "person.email", code: "PRIVATE" })
|
|
75
89
|
```
|
|
76
90
|
|
|
77
|
-
**Capture and replay** — cassettes
|
|
78
|
-
|
|
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
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
data/docs/transports.md
ADDED
|
@@ -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
|
-
|
|
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
|