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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1314 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -154
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -258
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -124
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +593 -334
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +9 -1
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +14 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +7 -0
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +794 -59
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +43 -8
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +160 -61
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +181 -21
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +28 -10
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +67 -14
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +389 -170
  62. 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
- s.description = "A typed GraphQL client for Ruby generate Sorbet T::Structs from queries, with federation, extensibility, and testing in mind"
5
+ # the README tagline, verbatimthe two pitches drifted apart once already,
6
+ # so spec/gemspec_spec.rb pins them together
7
+ s.description = "Your .graphql files, compiled into Sorbet types — and the fakes to test them."
6
8
  # ".yardopts" explicitly: `git ls-files *` skips dotfiles, and
7
9
  # rubydoc.info needs it shipped to render docstrings as markdown
8
10
  s.files = `git ls-files * ':!:spec' ':!:sorbet' ':!:bin' ':!:examples'`.split("\n") + [".yardopts"]
@@ -0,0 +1,259 @@
1
+ # typed: ignore — Rails::Generators DSL, only loaded by `rails g`
2
+ # frozen_string_literal: true
3
+
4
+ require "graph_weaver"
5
+
6
+ # rails g graph_weaver:install https://api.example.com/graphql
7
+ # rails g graph_weaver:install MyApp::Schema
8
+ # rails g graph_weaver:install db/schema.graphql
9
+ #
10
+ # Scaffolds the conventional layout — initializer, query/generated
11
+ # directories, editor config — and bootstraps the schema dump, so the
12
+ # setup in docs/getting_started.md is one command.
13
+ #
14
+ # The argument is what you'd pass to GraphWeaver.new, and the same three
15
+ # source forms are accepted; the initializer it writes reflects the one
16
+ # you chose. The source arrives on the command line rather than being read
17
+ # from config: at install time the initializer doesn't exist yet.
18
+ module GraphWeaver
19
+ module Generators
20
+ class InstallGenerator < Rails::Generators::Base
21
+ desc <<~TEXT
22
+ Wire up GraphWeaver: initializer, app/graphql layout, editor config, schema dump.
23
+
24
+ SOURCE is what you'd pass to GraphWeaver.new:
25
+
26
+ rails g graph_weaver:install https://api.example.com/graphql # an endpoint
27
+ rails g graph_weaver:install MyApp::Schema # a graphql-ruby schema, in-process
28
+ rails g graph_weaver:install db/schema.graphql # a schema dump you already have
29
+ TEXT
30
+
31
+ argument :source, type: :string, banner: "SOURCE",
32
+ desc: "what you'd pass to GraphWeaver.new: an endpoint url, a graphql-ruby schema class, or a schema dump path"
33
+
34
+ class_option :auth, type: :string,
35
+ desc: "name of the ENV var holding the auth token (url only) — default GRAPHWEAVER_AUTH"
36
+ class_option :schema, type: :boolean, default: true,
37
+ desc: "write the schema dump codegen reads"
38
+
39
+ # a Ruby constant path names a schema class; anything that is neither
40
+ # this nor a url is taken as a path to a dump
41
+ CONSTANT = /\A[A-Z]\w*(::[A-Z]\w*)*\z/
42
+
43
+ DEFAULT_AUTH = "GRAPHWEAVER_AUTH"
44
+
45
+ # Before anything is written: a mistyped source or a flag that doesn't
46
+ # apply to it is a mistake in the command just typed, so say so there
47
+ # rather than at boot, three files later.
48
+ def check_source
49
+ if options[:auth] && form != :url
50
+ raise Thor::Error, "--auth applies to a url — #{source} is a #{form == :schema_class ? "schema class" : "schema dump"}"
51
+ end
52
+
53
+ schema_class if form == :schema_class
54
+ end
55
+
56
+ # Every write goes through create_file, so a re-run prompts with a
57
+ # diff rather than overwriting an initializer you've edited.
58
+ def create_initializer
59
+ create_file "config/initializers/graph_weaver.rb", initializer
60
+ end
61
+
62
+ def create_layout
63
+ create_file File.join(GraphWeaver.queries_paths.first, ".keep"), ""
64
+ create_file File.join(GraphWeaver.generated_paths.first, ".keep"), ""
65
+ end
66
+
67
+ # editor autocomplete + validation for .graphql files (docs/editors.md)
68
+ def create_editor_config
69
+ create_file "graphql.config.yml", editor_config
70
+ end
71
+
72
+ # A url is introspected and a schema class dumped; a dump the app
73
+ # already has is left where it is (schema_path points at it instead).
74
+ def fetch_schema
75
+ return unless options[:schema] && form != :path
76
+
77
+ if form == :url
78
+ # pass the var name, not just the token — it lands in the dump's
79
+ # provenance so schema:refresh/:diff read the same one the
80
+ # initializer does, instead of defaulting to GRAPHWEAVER_AUTH
81
+ GraphWeaver::SchemaLoader.refresh!(url: source, auth_env: auth_var)
82
+ else
83
+ # a schema class is its own introspection source; ttl: 0 so an
84
+ # existing dump never counts as fresh
85
+ GraphWeaver::SchemaLoader.introspect(schema_class, cache: schema_path, ttl: 0)
86
+ end
87
+ say_status :introspect, "#{schema_path} from #{source}"
88
+ rescue StandardError => e
89
+ # the files above are the valuable part — don't lose them to a bad
90
+ # token or an unreachable host
91
+ say_status :failed, "#{e.message} — retry with `#{refresh_command}`", :red
92
+ end
93
+
94
+ def next_steps
95
+ say <<~TEXT
96
+
97
+ Write a query in #{GraphWeaver.queries_paths.first}, then:
98
+
99
+ rake graph_weaver:generate
100
+
101
+ Docs: https://github.com/dpep/graph_weaver/blob/main/docs/getting_started.md
102
+ TEXT
103
+
104
+ say federated_steps if subgraphs
105
+ end
106
+
107
+ private
108
+
109
+ # This install run is the one moment the user is guaranteed to be
110
+ # reading, and a composed supergraph changes what the next steps are:
111
+ # the test client is the interesting one, and there's a CI gate to add.
112
+ def federated_steps
113
+ <<~TEXT
114
+
115
+ #{source} is a composed supergraph (#{subgraphs.size} subgraphs: #{subgraphs.join(", ")}), so:
116
+
117
+ rake graph_weaver:federation:diff # CI gate: a subgraph changed, nobody recomposed
118
+ rake graph_weaver:federation:subgraphs # which schema here serves which subgraph
119
+
120
+ and specs run against your real resolvers across all of them, in-process:
121
+
122
+ describe "checkout", graphql: :router do ... end # require "graph_weaver/rspec"
123
+
124
+ Docs: https://github.com/dpep/graph_weaver/blob/main/docs/federation.md
125
+ TEXT
126
+ end
127
+
128
+ # The subgraph names this source composes, or nil when it isn't a
129
+ # composed supergraph. Read off the routing table rather than guessed —
130
+ # the same reader Testing::Router and federation:diff use.
131
+ def subgraphs
132
+ return @subgraphs if defined?(@subgraphs)
133
+
134
+ @subgraphs =
135
+ begin
136
+ (GraphWeaver::SchemaLoader.routing_table(source).subgraphs if form == :path)
137
+ rescue StandardError
138
+ # not a supergraph, or not readable — nothing to say either way
139
+ nil
140
+ end
141
+ end
142
+
143
+ # Which of GraphWeaver.new's source forms this is — the url test is
144
+ # its own, so the generator and the client can't disagree about what
145
+ # counts as one.
146
+ def form
147
+ @form ||=
148
+ if source.match?(GraphWeaver::Client::URL)
149
+ :url
150
+ elsif source.match?(CONSTANT)
151
+ :schema_class
152
+ else
153
+ :path
154
+ end
155
+ end
156
+
157
+ # The named schema class, resolved now: `rails g` boots the app, so a
158
+ # typo is catchable here rather than as a NameError at the next boot.
159
+ def schema_class
160
+ @schema_class ||= begin
161
+ klass = Object.const_get(source)
162
+ unless klass.respond_to?(:execute)
163
+ raise Thor::Error, "#{source} isn't a graphql-ruby schema (no .execute) — pass the class that inherits GraphQL::Schema"
164
+ end
165
+
166
+ klass
167
+ rescue NameError
168
+ raise Thor::Error, "uninitialized constant #{source} — pass your graphql-ruby schema class " \
169
+ "(rails g graphql:install writes app/graphql/<app>_schema.rb), an endpoint url, or a path to a schema dump"
170
+ end
171
+ end
172
+
173
+ # A dump the app already has stays where it is; every other form
174
+ # writes the conventional one.
175
+ def schema_path = (form == :path) ? source : GraphWeaver.schema_path
176
+
177
+ def auth_var = options[:auth] || DEFAULT_AUTH
178
+
179
+ def refresh_command
180
+ (form == :url) ? "rake graph_weaver:schema:refresh" : "rails g graph_weaver:install #{source}"
181
+ end
182
+
183
+ def initializer
184
+ <<~RUBY
185
+ # frozen_string_literal: true
186
+
187
+ #{client_setup}
188
+ # Custom scalars, enums and type mixins go here — `rake graph_weaver:generate`
189
+ # bakes them into the generated source, so they must be registered first:
190
+ #
191
+ # GraphWeaver.register_scalar("DateTime", Time, serialize: :iso8601, requires: "time")
192
+ # GraphWeaver.extend_type("Person", Greetable)
193
+ RUBY
194
+ end
195
+
196
+ # The load-bearing lines, per source form: what generated modules
197
+ # resolve to at execute time, and where codegen reads the schema.
198
+ def client_setup
199
+ case form
200
+ when :url
201
+ <<~RUBY
202
+ GraphWeaver.client = GraphWeaver.new(
203
+ "#{source}",
204
+ auth: ENV["#{auth_var}"],
205
+ cache: true, # reuse the committed dump; delete it to re-introspect
206
+ )
207
+ RUBY
208
+ when :schema_class
209
+ # to_prepare, not a bare assignment: the schema class is autoloaded,
210
+ # so it isn't resolvable this early, and a dev reload replaces it
211
+ # with a new class object the client would otherwise still hold.
212
+ <<~RUBY
213
+ Rails.application.config.to_prepare do
214
+ # queries run in-process against the app's own schema — no socket
215
+ GraphWeaver.client = GraphWeaver.new(#{source})
216
+ end
217
+ RUBY
218
+ when :path
219
+ if subgraphs
220
+ <<~RUBY
221
+ GraphWeaver.schema_path = "#{source}"
222
+
223
+ # A composed supergraph: your queries are generated against the whole
224
+ # graph, and a router serves it. Point the app default at the gateway:
225
+ #
226
+ # GraphWeaver.client = GraphWeaver.new("https://gateway.example.com/graphql")
227
+ #
228
+ # Specs don't need one — `graphql: :router` plans against this
229
+ # supergraph and runs your own subgraph resolvers in-process
230
+ # (docs/federation.md).
231
+ RUBY
232
+ else
233
+ <<~RUBY
234
+ GraphWeaver.schema_path = "#{source}"
235
+
236
+ # A dump is type information only — it has no resolvers, so it can't
237
+ # execute. Point the app default at whatever serves this API:
238
+ #
239
+ # GraphWeaver.client = GraphWeaver.new("https://api.example.com/graphql")
240
+ RUBY
241
+ end
242
+ end
243
+ end
244
+
245
+ # fragments are in documents: too — without them an editor reports
246
+ # `Unknown fragment` on any query that spreads a shared one
247
+ def editor_config
248
+ <<~YAML
249
+ # Autocomplete and validation for .graphql files in VS Code / RubyMine.
250
+ # https://github.com/dpep/graph_weaver/blob/main/docs/editors.md
251
+ schema: #{schema_path}
252
+ documents:
253
+ - #{GraphWeaver.queries_paths.first}/**/*.{graphql,gql}
254
+ - #{GraphWeaver.fragments_paths.first}/**/*.{graphql,gql}
255
+ YAML
256
+ end
257
+ end
258
+ end
259
+ end
@@ -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 + transport
18
- # github.execute!("query { viewer { login } }") # one-shot
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), or a
23
- # path/SDL/introspection dump via SchemaLoader. Pass transport: to
24
- # bring your own transport for a schema source.
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
- # 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.
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, &middleware)
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, "pass a url or transport:, not both" if transport
44
+ raise ArgumentError, "context: applies to a schema class executing in-process" if context
35
45
 
36
- @transport = wrap_retries(build_transport(source, auth:, headers:, &middleware), retries)
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
- @transport = transport || (source if source.is_a?(Module))
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
- # 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. A `Type.field` coordinate
80
- # (e.g. "User.birthday") overrides just that field. Same signature as
81
- # GraphWeaver.register_scalar.
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 + execute, returning the typed
143
- # Response envelope (execute! returns the result or raises). Variables
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 execute(query, **variables)
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(transport!, **kwargs)
113
+ mod.execute(**kwargs)
150
114
  end
151
115
 
152
- def execute!(query, **variables)
153
- execute(query, **variables).data!
116
+ def run!(query, **variables)
117
+ run(query, **variables).data!
154
118
  end
155
119
 
156
120
  private
157
121
 
158
- # Fail a typo'd registration at the call site when the schema is
159
- # already in hand (a schema-source client, or a url client after first
160
- # use) immediate feedback in consoles. Lazily-introspecting clients
161
- # get the same check at generation time instead; never trigger an
162
- # introspection just to validate a name.
163
- def validate_registration!(kind, name)
164
- return unless @schema
165
-
166
- GraphWeaver::Codegen.validate_registration!(@schema, kind, name)
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..."). Transport pick: Faraday when
171
- # the app already loads it (its middleware/proxy/timeout ecosystem
172
- # comes along), the zero-dependency Transport::HTTP otherwise.
173
- # Detection is `defined?(Faraday)` deliberately NOT a require:
174
- # faraday rides along transitively in most bundles (stripe, octokit,
175
- # ...), and try-requiring would switch transports on apps that never
176
- # chose it. With faraday under `require: false`, load it before
177
- # building the client.
178
- def build_transport(url, auth:, headers:, &middleware)
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
- if defined?(::Faraday)
185
- require_relative "transport/faraday"
186
- GraphWeaver::Transport::Faraday.new(url, headers:, &middleware)
187
- elsif middleware
188
- raise ArgumentError, "middleware blocks require the faraday gem"
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
- GraphWeaver::Transport::HTTP.new(url, headers:)
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)