graph_weaver 0.4.6 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1314 -0
- data/CLAUDE.md +100 -8
- data/DECISIONS.md +309 -0
- data/Gemfile.lock +23 -23
- data/NOTES.md +5 -5
- data/PLAN.md +106 -135
- data/README.md +115 -96
- data/REVIEW.md +946 -0
- data/docs/cassettes.md +75 -48
- data/docs/editors.md +82 -0
- data/docs/errors.md +32 -30
- data/docs/federation.md +520 -48
- data/docs/generated_modules.md +352 -137
- data/docs/getting_started.md +237 -67
- data/docs/logging.md +35 -6
- data/docs/real_world.md +21 -15
- data/docs/scalars.md +49 -154
- data/docs/testing.md +299 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +112 -0
- data/graph_weaver.gemspec +3 -1
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +114 -111
- data/lib/graph_weaver/codegen/aliases.rb +217 -0
- data/lib/graph_weaver/codegen/emit.rb +272 -258
- data/lib/graph_weaver/codegen/enum_type.rb +27 -124
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +593 -334
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +9 -1
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +14 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +67 -0
- data/lib/graph_weaver/query_module.rb +55 -0
- data/lib/graph_weaver/railtie.rb +23 -1
- data/lib/graph_weaver/representation.rb +74 -0
- data/lib/graph_weaver/response.rb +7 -0
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +794 -59
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +43 -8
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +160 -61
- data/lib/graph_weaver/testing/coverage.rb +165 -0
- data/lib/graph_weaver/testing/failure.rb +10 -23
- data/lib/graph_weaver/testing/fake_client.rb +181 -21
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1431 -0
- data/lib/graph_weaver/testing/subgraphs.rb +130 -0
- data/lib/graph_weaver/testing.rb +204 -14
- data/lib/graph_weaver/transport/faraday.rb +28 -10
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +67 -14
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +389 -170
- metadata +20 -3
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
|
-
|
|
5
|
+
# the README tagline, verbatim — the 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
|
data/lib/graph_weaver/client.rb
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
require_relative "codegen"
|
|
5
5
|
require_relative "errors"
|
|
6
6
|
require_relative "inflect"
|
|
7
|
+
require_relative "parsing"
|
|
7
8
|
require_relative "retry"
|
|
8
9
|
require_relative "schema_loader"
|
|
9
10
|
require_relative "transport/http"
|
|
@@ -12,31 +13,45 @@ require_relative "transport/http"
|
|
|
12
13
|
# generation:
|
|
13
14
|
#
|
|
14
15
|
# github = GraphWeaver.new("https://api.github.com/graphql", auth: token, cache: true)
|
|
15
|
-
# github.register_scalar("DateTime", Time, serialize: :iso8601, requires: "time")
|
|
16
16
|
#
|
|
17
|
-
# RepoQuery = github.parse("queries/repo.graphql") # implicit schema +
|
|
18
|
-
# github.
|
|
17
|
+
# RepoQuery = github.parse("queries/repo.graphql") # implicit schema + client
|
|
18
|
+
# github.run!("query { viewer { login } }") # one-shot
|
|
19
19
|
#
|
|
20
20
|
# The first argument is a url (a transport is built; the schema comes
|
|
21
21
|
# from introspection on first use, cached per cache:/ttl:) or a schema
|
|
22
|
-
# source — a live schema class (which also executes in-process
|
|
23
|
-
#
|
|
24
|
-
#
|
|
22
|
+
# source — a live schema class (which also executes in-process, through
|
|
23
|
+
# an InProcess wrapper that takes context:), or a path/SDL/introspection
|
|
24
|
+
# dump via SchemaLoader.
|
|
25
25
|
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
26
|
+
# transport: means "which transport" alongside a url — :http (the
|
|
27
|
+
# default, always, whatever else the Gemfile loads) or :faraday — and
|
|
28
|
+
# "this transport" alongside a schema source.
|
|
29
|
+
#
|
|
30
|
+
# Clients are independent: each has its own transport and schema, so one
|
|
31
|
+
# app can talk to several GraphQL servers. Scalar/enum/type registrations
|
|
32
|
+
# are a codegen concern and live in one global registry (see
|
|
33
|
+
# GraphWeaver.register_scalar) — the same registry the rake tasks bake.
|
|
29
34
|
class GraphWeaver::Client
|
|
35
|
+
include GraphWeaver::Parsing
|
|
36
|
+
|
|
30
37
|
URL = %r{\Ahttps?://}i
|
|
31
38
|
|
|
32
|
-
def initialize(source, auth: nil, headers: {}, retries: false, transport: nil, cache: nil, ttl: nil,
|
|
39
|
+
def initialize(source, auth: nil, headers: {}, retries: false, transport: nil, cache: nil, ttl: nil,
|
|
40
|
+
open_timeout: nil, read_timeout: nil, context: nil, &middleware)
|
|
41
|
+
check_source!(source)
|
|
42
|
+
|
|
33
43
|
if source.is_a?(String) && source.match?(URL)
|
|
34
|
-
raise ArgumentError, "
|
|
44
|
+
raise ArgumentError, "context: applies to a schema class executing in-process" if context
|
|
35
45
|
|
|
36
|
-
|
|
46
|
+
built = build_transport(source, auth:, headers:, kind: transport, open_timeout:, read_timeout:, &middleware)
|
|
47
|
+
@transport = wrap_retries(built, retries)
|
|
37
48
|
else
|
|
38
|
-
if auth || middleware || retries
|
|
39
|
-
raise ArgumentError, "auth:/retries:/middleware apply to a url — got a schema source"
|
|
49
|
+
if auth || middleware || retries || open_timeout || read_timeout
|
|
50
|
+
raise ArgumentError, "auth:/retries:/timeouts/middleware apply to a url — got a schema source"
|
|
51
|
+
end
|
|
52
|
+
if transport.is_a?(Symbol)
|
|
53
|
+
# naming a bundled transport only builds one from a url
|
|
54
|
+
raise ArgumentError, "transport: #{transport.inspect} needs a url — got a schema source; pass a built transport"
|
|
40
55
|
end
|
|
41
56
|
if cache || ttl
|
|
42
57
|
# a schema source never introspects, so a cache would silently no-op
|
|
@@ -46,14 +61,21 @@ class GraphWeaver::Client
|
|
|
46
61
|
# a live schema class doubles as an in-process transport; a loaded
|
|
47
62
|
# dump has no resolvers, so it is type information only
|
|
48
63
|
@schema = source.is_a?(Module) ? source : GraphWeaver::SchemaLoader.load(source)
|
|
49
|
-
|
|
64
|
+
|
|
65
|
+
if context && !(source.is_a?(Module) && transport.nil?)
|
|
66
|
+
# nothing would ever read it — a dump has no resolvers, and an
|
|
67
|
+
# explicit transport carries its own
|
|
68
|
+
raise ArgumentError, "context: applies to a schema class executing in-process"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# InProcess adds context:, logging and branded errors to the bare
|
|
72
|
+
# schema class, which stays usable on its own everywhere else
|
|
73
|
+
@transport = transport ||
|
|
74
|
+
(GraphWeaver::InProcess.new(source, context: context || {}) if source.is_a?(Module))
|
|
50
75
|
end
|
|
51
76
|
|
|
52
77
|
@cache = cache
|
|
53
78
|
@ttl = ttl
|
|
54
|
-
@scalars = {}
|
|
55
|
-
@enums = {}
|
|
56
|
-
@types = {}
|
|
57
79
|
end
|
|
58
80
|
|
|
59
81
|
# The transport queries run through: a url-built transport, an
|
|
@@ -74,123 +96,104 @@ class GraphWeaver::Client
|
|
|
74
96
|
@schema ||= GraphWeaver::SchemaLoader.introspect(transport!, cache: @cache, ttl: @ttl)
|
|
75
97
|
end
|
|
76
98
|
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
|
|
83
|
-
validate_registration!("scalar", graphql_name.to_s)
|
|
84
|
-
@scalars[graphql_name.to_s] =
|
|
85
|
-
GraphWeaver::Codegen::ScalarType.new(graphql_name, type, cast:, serialize:, requires:, coerce:)
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Client-scoped enum mapping: this client's generated code speaks your
|
|
89
|
-
# T::Enum for the named GraphQL enum (see Codegen::EnumType — inference
|
|
90
|
-
# by name, map: for renames, fallback: to absorb unknown wire values).
|
|
91
|
-
def register_enum(graphql_name, type, map: nil, fallback: nil, requires: nil)
|
|
92
|
-
validate_registration!("enum", graphql_name.to_s)
|
|
93
|
-
@enums[graphql_name.to_s] =
|
|
94
|
-
GraphWeaver::Codegen::EnumType.new(graphql_name, type, map:, fallback:, requires:)
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
# Bulk, inference-only form: register_enums("Species" => PetKind, ...)
|
|
98
|
-
def register_enums(mappings)
|
|
99
|
-
mappings.each { |graphql_name, type| register_enum(graphql_name, type) }
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
# Client-scoped type helpers: include app-owned modules into every
|
|
103
|
-
# struct this client generates from the named GraphQL type — pass
|
|
104
|
-
# modules, or a block to build one inline. Additive with global
|
|
105
|
-
# registrations (see GraphWeaver.extend_type).
|
|
106
|
-
def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
|
|
107
|
-
validate_registration!("type", graphql_name.to_s)
|
|
108
|
-
aliases = GraphWeaver::Codegen.take_aliases(kw)
|
|
109
|
-
entry = @types[graphql_name.to_s] ||= { mixins: [], requires: [], aliases: {} }
|
|
110
|
-
GraphWeaver::Codegen.add_type_helpers(entry, graphql_name, mixins, requires, block, aliases)
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
# Parse a query (a .graphql path or raw string) into a typed module
|
|
114
|
-
# bound to this client's schema, scalars, enums, helpers, and transport
|
|
115
|
-
# (including a live schema class executing in-process — the module came
|
|
116
|
-
# from this client, so it runs against it; pass a client per call to
|
|
117
|
-
# override, e.g. with a fake).
|
|
118
|
-
def parse(query, name: nil)
|
|
119
|
-
GraphWeaver.parse(schema:, query:, name:, client: transport,
|
|
120
|
-
scalars: @scalars, enums: @enums, types: @types)
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
# Parse every .graphql query in a directory into typed modules, named
|
|
124
|
-
# like generation would name them — the no-build-step analog of
|
|
125
|
-
# generate! + load_generated!:
|
|
126
|
-
#
|
|
127
|
-
# github.load_queries! # queries/person.graphql => ::PersonQuery
|
|
128
|
-
# github.load_queries!(namespace: Github) # => Github::PersonQuery
|
|
129
|
-
#
|
|
130
|
-
# Reloadable (constants are replaced), so it suits consoles and dev.
|
|
131
|
-
# Returns the modules.
|
|
132
|
-
def load_queries!(dir = nil, namespace: Object)
|
|
133
|
-
dirs = dir ? [dir] : GraphWeaver.queries_paths
|
|
134
|
-
dirs.flat_map { |d| Dir[File.join(d, "*.graphql")].sort }.map do |path|
|
|
135
|
-
name = "#{GraphWeaver::Inflect.camelize(File.basename(path, ".graphql"))}Query"
|
|
136
|
-
namespace.send(:remove_const, name) if namespace.const_defined?(name, false)
|
|
137
|
-
GraphWeaver.log(:info) { "loaded #{name} from #{path}" }
|
|
138
|
-
namespace.const_set(name, parse(path))
|
|
139
|
-
end
|
|
99
|
+
# The client contract, same as every transport: a query and its
|
|
100
|
+
# variables in, the raw response hash out. (#run is the one-shot that
|
|
101
|
+
# parses and returns the typed envelope.)
|
|
102
|
+
def execute(query, variables: {}, operation_name: nil)
|
|
103
|
+
transport!.execute(query, variables:, operation_name:)
|
|
140
104
|
end
|
|
141
105
|
|
|
142
|
-
# One-shot dynamic execution — parse +
|
|
143
|
-
# Response envelope (
|
|
106
|
+
# One-shot dynamic execution — parse + run, returning the typed
|
|
107
|
+
# Response envelope (run! returns the result or raises). Variables
|
|
144
108
|
# are plain kwargs, exactly as on a generated module; graphql-cased
|
|
145
109
|
# string keys work too.
|
|
146
|
-
def
|
|
110
|
+
def run(query, **variables)
|
|
147
111
|
mod = parse(query)
|
|
148
112
|
kwargs = variables.to_h { |key, value| [GraphWeaver::Inflect.underscore(key.to_s).to_sym, value] }
|
|
149
|
-
mod.execute(
|
|
113
|
+
mod.execute(**kwargs)
|
|
150
114
|
end
|
|
151
115
|
|
|
152
|
-
def
|
|
153
|
-
|
|
116
|
+
def run!(query, **variables)
|
|
117
|
+
run(query, **variables).data!
|
|
154
118
|
end
|
|
155
119
|
|
|
156
120
|
private
|
|
157
121
|
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
def
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
122
|
+
# Anything already speaking the client contract — another Client,
|
|
123
|
+
# InProcess, Retry, a transport, a fake, the test router — carries no
|
|
124
|
+
# schema to generate from, so it can't stand in as the schema source.
|
|
125
|
+
# Without this it is handed to SchemaLoader and fails as `undefined
|
|
126
|
+
# method 'lstrip'`.
|
|
127
|
+
def check_source!(source)
|
|
128
|
+
# a graphql-ruby schema class executes too, and *is* a schema source
|
|
129
|
+
return if source.is_a?(Module) || !source.respond_to?(:execute)
|
|
130
|
+
|
|
131
|
+
raise GraphWeaver::Error, "#{source.class} is a client, not a schema source — pass the schema, and this " \
|
|
132
|
+
"as its transport: GraphWeaver.new(schema, transport: client). For a live schema class " \
|
|
133
|
+
"with a context: GraphWeaver.new(schema, context: { ... })."
|
|
167
134
|
end
|
|
168
135
|
|
|
169
136
|
# auth: is a token — "Bearer" is assumed unless the string carries its
|
|
170
|
-
# own scheme ("Basic dXNlcjpwYXNz...").
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
|
|
137
|
+
# own scheme ("Basic dXNlcjpwYXNz...").
|
|
138
|
+
#
|
|
139
|
+
# Transport pick: always Transport::HTTP unless you ask for Faraday
|
|
140
|
+
# (transport: :faraday, or a middleware block, which is Faraday's
|
|
141
|
+
# anyway). Deliberately NOT `defined?(Faraday)`: faraday rides along
|
|
142
|
+
# transitively in most bundles (stripe, octokit, ...), so sniffing for
|
|
143
|
+
# it lets an unrelated gem swap your transport — along with its
|
|
144
|
+
# timeouts and, since Faraday's default net_http adapter reconnects
|
|
145
|
+
# per request, your connection reuse. Same code, same transport.
|
|
146
|
+
def build_transport(url, auth:, headers:, kind:, open_timeout: nil, read_timeout: nil, &middleware)
|
|
179
147
|
headers = headers.dup
|
|
180
148
|
if auth
|
|
181
149
|
headers["Authorization"] ||= auth.include?(" ") ? auth : "Bearer #{auth}"
|
|
182
150
|
end
|
|
183
151
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
152
|
+
# nil means "the transport's default" — both bundled ones agree on it
|
|
153
|
+
timeouts = { open_timeout:, read_timeout: }.compact
|
|
154
|
+
|
|
155
|
+
transport =
|
|
156
|
+
if transport_kind(kind, middleware) == :faraday
|
|
157
|
+
build_faraday(url, headers:, timeouts:, &middleware)
|
|
158
|
+
else
|
|
159
|
+
GraphWeaver::Transport::HTTP.new(url, headers:, **timeouts)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
GraphWeaver.log(:info) { "transport: #{transport.class} -> #{url}" }
|
|
163
|
+
transport
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Which bundled transport a url client builds: the explicit
|
|
167
|
+
# transport:, else Faraday when a middleware block asks for it.
|
|
168
|
+
def transport_kind(kind, middleware)
|
|
169
|
+
case kind
|
|
170
|
+
when nil then middleware ? :faraday : :http
|
|
171
|
+
when :faraday then :faraday
|
|
172
|
+
when :http
|
|
173
|
+
raise ArgumentError, "middleware blocks are Faraday's — pass transport: :faraday" if middleware
|
|
174
|
+
|
|
175
|
+
:http
|
|
189
176
|
else
|
|
190
|
-
|
|
177
|
+
raise ArgumentError, "transport: takes :http or :faraday alongside a url, got #{kind.inspect}"
|
|
191
178
|
end
|
|
192
179
|
end
|
|
193
180
|
|
|
181
|
+
# The faraday gem is optional, so a missing one reads as a Gemfile
|
|
182
|
+
# problem rather than a stack trace out of require.
|
|
183
|
+
def build_faraday(url, headers:, timeouts:, &middleware)
|
|
184
|
+
begin
|
|
185
|
+
require_relative "transport/faraday"
|
|
186
|
+
rescue LoadError
|
|
187
|
+
nil # reported below, alongside a gem that loaded but wasn't there
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
unless defined?(::Faraday)
|
|
191
|
+
raise ArgumentError, "the faraday transport needs the faraday gem — add it to your Gemfile"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
GraphWeaver::Transport::Faraday.new(url, headers:, **timeouts, &middleware)
|
|
195
|
+
end
|
|
196
|
+
|
|
194
197
|
# retries: is off by default — true for Retry defaults, or a
|
|
195
198
|
# Hash of its options
|
|
196
199
|
def wrap_retries(transport, retries)
|