graph_weaver 0.6.1 → 0.7.1

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +8 -0
  3. data/Gemfile.lock +153 -4
  4. data/README.md +45 -79
  5. data/docs/alternatives.md +195 -0
  6. data/docs/cassettes.md +61 -50
  7. data/docs/editors.md +32 -47
  8. data/docs/errors.md +360 -103
  9. data/docs/federation.md +692 -473
  10. data/docs/generated_modules.md +441 -314
  11. data/docs/getting_started.md +370 -194
  12. data/docs/i18n.md +171 -0
  13. data/docs/logging.md +197 -50
  14. data/docs/real_world.md +42 -27
  15. data/docs/scalars.md +307 -176
  16. data/docs/testing.md +473 -220
  17. data/docs/transports.md +224 -151
  18. data/docs/upgrading.md +258 -305
  19. data/examples/README.md +38 -0
  20. data/examples/countries.rb +39 -0
  21. data/examples/federation.rb +62 -0
  22. data/examples/github/generate.rb +20 -0
  23. data/examples/github/generated/star_mutation.rb +126 -0
  24. data/examples/github/generated/stargazers_query.rb +232 -0
  25. data/examples/github/generated/starred_query.rb +151 -0
  26. data/examples/github/queries/star.graphql +8 -0
  27. data/examples/github/queries/stargazers.graphql +22 -0
  28. data/examples/github/queries/starred.graphql +11 -0
  29. data/examples/github/run.rb +43 -0
  30. data/examples/github/setup.rb +18 -0
  31. data/examples/rick_and_morty.rb +57 -0
  32. data/graph_weaver.gemspec +19 -3
  33. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  34. data/lib/graph_weaver/client.rb +69 -11
  35. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  36. data/lib/graph_weaver/codegen/emit.rb +98 -29
  37. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  38. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  39. data/lib/graph_weaver/codegen/registry.rb +175 -0
  40. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  41. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  42. data/lib/graph_weaver/codegen.rb +406 -195
  43. data/lib/graph_weaver/coerce.rb +155 -26
  44. data/lib/graph_weaver/context_seam.rb +54 -0
  45. data/lib/graph_weaver/errors.rb +284 -46
  46. data/lib/graph_weaver/federation.rb +129 -27
  47. data/lib/graph_weaver/graph.rb +315 -0
  48. data/lib/graph_weaver/hints.rb +100 -24
  49. data/lib/graph_weaver/in_process.rb +27 -15
  50. data/lib/graph_weaver/input_struct.rb +119 -32
  51. data/lib/graph_weaver/internal/endpoint.rb +80 -0
  52. data/lib/graph_weaver/internal/headers.rb +70 -0
  53. data/lib/graph_weaver/internal/overrides.rb +67 -5
  54. data/lib/graph_weaver/internal/planner.rb +45 -15
  55. data/lib/graph_weaver/internal/refusal.rb +49 -0
  56. data/lib/graph_weaver/internal/schemas.rb +23 -9
  57. data/lib/graph_weaver/internal/selection.rb +34 -0
  58. data/lib/graph_weaver/internal/server_input.rb +251 -0
  59. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  60. data/lib/graph_weaver/internal/unused.rb +287 -0
  61. data/lib/graph_weaver/internal/values.rb +40 -4
  62. data/lib/graph_weaver/internal.rb +249 -14
  63. data/lib/graph_weaver/log_subscriber.rb +74 -0
  64. data/lib/graph_weaver/logging.rb +163 -19
  65. data/lib/graph_weaver/query_module.rb +44 -3
  66. data/lib/graph_weaver/railtie.rb +237 -17
  67. data/lib/graph_weaver/representation.rb +55 -17
  68. data/lib/graph_weaver/result_struct.rb +90 -0
  69. data/lib/graph_weaver/retry.rb +45 -13
  70. data/lib/graph_weaver/rspec.rb +404 -93
  71. data/lib/graph_weaver/schema_loader.rb +266 -56
  72. data/lib/graph_weaver/tasks.rb +380 -89
  73. data/lib/graph_weaver/testing/cassette.rb +34 -10
  74. data/lib/graph_weaver/testing/endpoint.rb +107 -0
  75. data/lib/graph_weaver/testing/failure.rb +69 -12
  76. data/lib/graph_weaver/testing/fake_client.rb +164 -45
  77. data/lib/graph_weaver/testing/router.rb +64 -13
  78. data/lib/graph_weaver/testing.rb +200 -58
  79. data/lib/graph_weaver/transport/faraday.rb +41 -8
  80. data/lib/graph_weaver/transport/http.rb +48 -6
  81. data/lib/graph_weaver/transport.rb +134 -27
  82. data/lib/graph_weaver/version.rb +1 -1
  83. data/lib/graph_weaver.rb +495 -106
  84. metadata +71 -3
  85. data/CHANGELOG.md +0 -2355
@@ -0,0 +1,22 @@
1
+ query($owner: String!, $name: String!, $first: Int!) {
2
+ repository(owner: $owner, name: $name) {
3
+ id
4
+ nameWithOwner
5
+ stargazerCount
6
+ stargazers(first: $first, orderBy: { field: STARRED_AT, direction: DESC }) {
7
+ edges {
8
+ starredAt
9
+ node {
10
+ login
11
+ name
12
+ repositories(first: 2, orderBy: { field: STARGAZERS, direction: DESC }) {
13
+ nodes {
14
+ nameWithOwner
15
+ stargazerCount
16
+ }
17
+ }
18
+ }
19
+ }
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,11 @@
1
+ query($login: String!, $first: Int!) {
2
+ user(login: $login) {
3
+ starredRepositories(first: $first, orderBy: { field: STARRED_AT, direction: DESC }) {
4
+ totalCount
5
+ nodes {
6
+ nameWithOwner
7
+ stargazerCount
8
+ }
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env ruby
2
+ # typed: false
3
+ # frozen_string_literal: true
4
+
5
+ # Star graph_weaver ⭐ (thanks!), then meet your fellow stargazers —
6
+ # who they are, their biggest repos, and what else they've starred:
7
+ #
8
+ # examples/github/run.rb
9
+ require_relative "setup"
10
+
11
+ # the checked-in typed modules (regenerate: examples/github/generate.rb)
12
+ Dir[File.join(__dir__, "generated", "*.rb")].sort.each { |file| require file }
13
+
14
+ OWNER = "dpep"
15
+ NAME = "graph_weaver"
16
+
17
+ repo = StargazersQuery.execute!(owner: OWNER, name: NAME, first: 1).repository
18
+ abort "repository not found" unless repo
19
+
20
+ # join the club (idempotent — starring twice is fine)
21
+ starrable = StarMutation.execute!(id: repo.id).add_star&.starrable
22
+ puts "⭐ starred #{repo.name_with_owner} — #{starrable&.stargazer_count} star(s). Thanks!"
23
+
24
+ # refreshed, so the list includes you
25
+ repo = StargazersQuery.execute!(owner: OWNER, name: NAME, first: 10).repository
26
+
27
+ puts "\nThe stargazers:"
28
+ repo.stargazers.edges&.each do |edge|
29
+ gazer = edge&.node
30
+ next unless gazer
31
+
32
+ who = gazer.name ? "#{gazer.login} (#{gazer.name})" : gazer.login
33
+ top = gazer.repositories.nodes&.compact&.map { |r| "#{r.name_with_owner} ⭐#{r.stargazer_count}" }
34
+ puts " #{who} — starred #{edge.starred_at&.strftime("%Y-%m-%d")}"
35
+ puts " top repos: #{top.join(", ")}" if top&.any?
36
+
37
+ # drill down: what else have they starred lately?
38
+ starred = StarredQuery.execute!(login: gazer.login, first: 3).user&.starred_repositories
39
+ next unless starred
40
+
41
+ also = starred.nodes&.compact&.reject { |r| r.name_with_owner == repo.name_with_owner }
42
+ puts " also starred (#{starred.total_count} total): #{also.map(&:name_with_owner).join(", ")}" if also&.any?
43
+ end
@@ -0,0 +1,18 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ # Shared wiring for the GitHub example: auth and the client.
5
+ require_relative "../../lib/graph_weaver"
6
+
7
+ token = ENV["GITHUB_TOKEN"] || `gh auth token 2>/dev/null`.strip
8
+ abort "need a token: `gh auth login`, or GITHUB_TOKEN=..." if token.empty?
9
+
10
+ GraphWeaver.client = GraphWeaver.new(
11
+ "https://api.github.com/graphql",
12
+ auth: token,
13
+ # used by generate.rb (and any dynamic parse): the first introspection
14
+ # of GitHub's large schema dumps here — gitignored, a few seconds once,
15
+ # instant after. run.rb's checked-in generated modules never introspect,
16
+ # so running it alone won't create this file.
17
+ cache: File.join(__dir__, "schema.json"),
18
+ )
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env ruby
2
+ # typed: false
3
+ # frozen_string_literal: true
4
+
5
+ # One notch up from countries.rb: filtered search, pagination, an aliased
6
+ # field, and a block-built type helper — against the Rick and Morty API
7
+ # (free, no auth, wubba lubba dub dub):
8
+ #
9
+ # examples/rick_and_morty.rb [NAME]
10
+ # examples/rick_and_morty.rb morty
11
+ require_relative "../lib/graph_weaver"
12
+
13
+ api = GraphWeaver.new("https://rickandmortyapi.com/graphql")
14
+
15
+ # decorate every Character struct generated from this type — derived values
16
+ # live as methods, the wire data stays honest
17
+ GraphWeaver.extend_type("Character") do
18
+ def emoji
19
+ { "Alive" => "🟢", "Dead" => "💀" }.fetch(status, "❓")
20
+ end
21
+ end
22
+
23
+ CharacterQuery = api.parse(<<~GRAPHQL)
24
+ query($name: String, $page: Int) {
25
+ characters(page: $page, filter: { name: $name }) {
26
+ # a GraphQL alias names the prop: `info.next_page`, not `info.next`
27
+ info { count pages nextPage: next }
28
+ results {
29
+ name
30
+ status
31
+ species
32
+ origin { name }
33
+ episode { name }
34
+ }
35
+ }
36
+ }
37
+ GRAPHQL
38
+
39
+ name = ARGV.first || "smith"
40
+ page = 1
41
+ total = nil
42
+
43
+ loop do
44
+ characters = CharacterQuery.execute!(name:, page:).characters
45
+ abort "no characters match #{name.inspect}" if characters&.results.to_a.empty?
46
+
47
+ total ||= characters.info&.count
48
+ characters.results.compact.each do |character|
49
+ debut = character.episode.compact.first&.name
50
+ puts "#{character.emoji} #{character.name} — #{character.species} from #{character.origin&.name}, debuted in #{debut.inspect}"
51
+ end
52
+
53
+ page = characters.info&.next_page
54
+ break unless page
55
+ end
56
+
57
+ puts "\n#{total} character(s) matched #{name.inspect}"
data/graph_weaver.gemspec CHANGED
@@ -10,9 +10,14 @@ Gem::Specification.new do |s|
10
10
  # CLAUDE.md/PLAN.md/REVIEW.md/NOTES.md/DECISIONS.md are written for whoever
11
11
  # works on the gem, not whoever installs it — and REVIEW.md carries examples
12
12
  # from before the API it describes was rewritten
13
- s.files = `git ls-files * ':!:spec' ':!:sorbet' ':!:bin' ':!:examples' \
13
+ # examples/ ships (small plain text) so the README's links to it resolve for
14
+ # someone who only has the installed gem, not a checkout
15
+ # CHANGELOG.md doesn't (259 KB, ~16% of the package) — changelog_uri below
16
+ # points at the GitHub copy instead
17
+ s.files = `git ls-files * ':!:spec' ':!:sorbet' ':!:bin' \
14
18
  ':!:CLAUDE.md' ':!:PLAN.md' ':!:REVIEW.md' ':!:NOTES.md' \
15
- ':!:DECISIONS.md' ':!:Makefile' ':!:design'`.split("\n") + [".yardopts"]
19
+ ':!:DECISIONS.md' ':!:CHANGELOG.md' ':!:Makefile' ':!:design' \
20
+ ':!:research'`.split("\n") + [".yardopts"]
16
21
  s.homepage = "https://github.com/dpep/graph_weaver"
17
22
  s.license = "MIT"
18
23
  s.name = "graph_weaver"
@@ -22,8 +27,12 @@ Gem::Specification.new do |s|
22
27
 
23
28
  s.metadata = {
24
29
  "bug_tracker_uri" => "#{s.homepage}/issues",
25
- "changelog_uri" => "#{s.homepage}/blob/main/CHANGELOG.md",
30
+ # pinned to the release tag, not mainCHANGELOG.md isn't packaged, and
31
+ # main drifts ahead of whatever version this metadata shipped with
32
+ "changelog_uri" => "#{s.homepage}/blob/v#{s.version}/CHANGELOG.md",
26
33
  "documentation_uri" => "#{s.homepage}/tree/main/docs",
34
+ # no separate homepage_uri: identical to s.homepage, and `gem build` warns
35
+ # that rubygems.org only shows one of two metadata keys with the same uri
27
36
  "rubygems_mfa_required" => "true",
28
37
  "source_code_uri" => s.homepage,
29
38
  }
@@ -40,12 +49,19 @@ Gem::Specification.new do |s|
40
49
  s.add_development_dependency "debug"
41
50
  s.add_development_dependency "faker"
42
51
  s.add_development_dependency "faraday"
52
+ s.add_development_dependency "rack" # WebMock's to_rack needs it; webmock doesn't depend on it
43
53
  s.add_development_dependency "rake"
54
+ # spec/railtie_spec.rb boots a real Rails application: the railtie's bug of
55
+ # record was Rails' initializer TSort putting graph_weaver.logger after
56
+ # config/initializers, which a stand-in cannot model. Brings activesupport,
57
+ # which LogSubscriber is checked against for the same reason.
58
+ s.add_development_dependency "railties"
44
59
  s.add_development_dependency "redcarpet" # yard --markup markdown
45
60
  s.add_development_dependency "rspec"
46
61
  s.add_development_dependency "simplecov"
47
62
  s.add_development_dependency "sorbet"
48
63
  s.add_development_dependency "tapioca"
64
+ s.add_development_dependency "webmock" # graphql: :wire serves its Rack app through it
49
65
  s.add_development_dependency "webrick"
50
66
  s.add_development_dependency "yard"
51
67
  end
@@ -69,10 +69,86 @@ module GraphWeaver
69
69
  create_file "graphql.config.yml", editor_config
70
70
  end
71
71
 
72
+ # Generated files are machine-written and say "do not edit", but plain
73
+ # `rubocop` still fires Style/Documentation, Style/ClassAndModuleChildren
74
+ # and Metrics/* on every one of them. Only an app that already lints is
75
+ # touched: writing a .rubocop.yml would turn rubocop on for a project
76
+ # that never asked for it.
77
+ def exclude_generated_from_rubocop
78
+ return unless File.exist?(rubocop_config)
79
+
80
+ body = File.read(rubocop_config)
81
+ # already excluded — a re-run, or done by hand
82
+ globs = generated_globs.reject { |glob| body.include?(glob) }
83
+ return if globs.empty?
84
+
85
+ entries = globs.map { |glob| " - #{glob.inspect}" }.join("\n")
86
+ reason =
87
+ if body.match?(/^AllCops:/)
88
+ # rubocop takes the LAST of two duplicate keys, so appending a second
89
+ # AllCops: would replace the app's own rather than add to it
90
+ "sets AllCops already, and a second one would replace it rather than merge"
91
+ elsif yaml_documents(body) > 1
92
+ # rubocop reads only the first document, so the append lands where
93
+ # nothing will ever read it
94
+ "holds more than one YAML document, and rubocop reads only the first"
95
+ end
96
+
97
+ # Name the lines instead of guessing where inside theirs they belong.
98
+ if reason
99
+ say <<~TEXT
100
+
101
+ #{RUBOCOP_CONFIG} #{reason} — so add this under AllCops/Exclude:
102
+
103
+ #{entries}
104
+ TEXT
105
+ else
106
+ # inherit_mode is what makes this an addition: rubocop REPLACES an
107
+ # Exclude array on merge, so without it the block below wipes the
108
+ # effective list — rubocop's own vendor/node_modules/tmp defaults
109
+ # included, along with any Exclude reaching here through inherit_from.
110
+ append_to_file RUBOCOP_CONFIG, <<~YAML + entries + "\n"
111
+
112
+ # Machine-written by `rake graph_weaver:generate` — not yours to style.
113
+ AllCops:
114
+ inherit_mode:
115
+ merge:
116
+ - Exclude
117
+ Exclude:
118
+ YAML
119
+ end
120
+ end
121
+
122
+ # The `graphql:` tags need this require, and it has to be somewhere
123
+ # rspec actually loads. A spec/support file is not: rspec-rails ships
124
+ # the spec/support glob commented out, so the require sat there doing
125
+ # nothing and a tagged example silently ran against the real client.
126
+ def wire_rspec
127
+ helper = RSPEC_HELPERS.find { |path| File.exist?(File.join(GraphWeaver.root, path)) }
128
+
129
+ unless helper
130
+ say "\nTesting: add `#{RSPEC_REQUIRE}` to your spec helper for the " \
131
+ "`graphql:` tags (docs/testing.md)."
132
+ return
133
+ end
134
+
135
+ body = File.read(File.join(GraphWeaver.root, helper))
136
+ return if body.match?(REQUIRED_ALREADY) # a re-run, or done by hand
137
+
138
+ # after rspec-rails' own require where there is one, at the end
139
+ # otherwise — either way top-level in a file every spec loads
140
+ if (anchor = body[RSPEC_RAILS_REQUIRE])
141
+ insert_into_file helper, "#{RSPEC_REQUIRE}\n", after: anchor
142
+ else
143
+ append_to_file helper, "\n#{RSPEC_REQUIRE}\n"
144
+ end
145
+ end
146
+
72
147
  # A url is introspected and a schema class dumped; a dump the app
73
148
  # already has is left where it is (schema_path points at it instead).
74
149
  def fetch_schema
75
150
  return unless options[:schema] && form != :path
151
+ return if keep_existing_dump
76
152
 
77
153
  if form == :url
78
154
  # pass the var name, not just the token — it lands in the dump's
@@ -114,6 +190,33 @@ module GraphWeaver
114
190
 
115
191
  private
116
192
 
193
+ RUBOCOP_CONFIG = ".rubocop.yml"
194
+
195
+ # rails_helper first: rspec-rails writes both, and only rails_helper
196
+ # has Rails booted by the time the require runs.
197
+ RSPEC_HELPERS = ["spec/rails_helper.rb", "spec/spec_helper.rb"].freeze
198
+ RSPEC_REQUIRE = 'require "graph_weaver/rspec"'
199
+ # the newline is part of the anchor: Thor inserts directly after the
200
+ # match, so without it the require lands on the end of that line
201
+ RSPEC_RAILS_REQUIRE = %r{^require ["']rspec/rails["'].*\n}
202
+ REQUIRED_ALREADY = %r{^\s*require ["']graph_weaver/rspec["']}
203
+
204
+ def rubocop_config = File.join(GraphWeaver.root, RUBOCOP_CONFIG)
205
+
206
+ # Parsed, not counted: a `---` can also be a line inside a block scalar.
207
+ # A file rubocop itself can't read is left to rubocop to complain about.
208
+ def yaml_documents(body)
209
+ YAML.parse_stream(body).children.size
210
+ rescue Psych::SyntaxError
211
+ 1
212
+ end
213
+
214
+ # Every graph's output directory, so a multi-schema app is covered by
215
+ # the same run — read off the graphs rather than restated here.
216
+ def generated_globs
217
+ GraphWeaver.graphs.map { |graph| File.join(graph.output, "**/*") }.uniq
218
+ end
219
+
117
220
  # This install run is the one moment the user is guaranteed to be
118
221
  # reading, and a composed supergraph changes what the next steps are:
119
222
  # the test client is the interesting one, and there's a CI gate to add.
@@ -141,9 +244,13 @@ module GraphWeaver
141
244
 
142
245
  @subgraphs =
143
246
  begin
144
- (GraphWeaver::SchemaLoader.routing_table(source).subgraphs if form == :path)
247
+ # asked, not rescued: the refusal writes a warn line as it is
248
+ # built, and "this isn't a supergraph" is the ordinary answer
249
+ if form == :path && GraphWeaver::SchemaLoader.routing_table?(source)
250
+ GraphWeaver::SchemaLoader.routing_table(source).subgraphs
251
+ end
145
252
  rescue StandardError
146
- # not a supergraph, or not readable — nothing to say either way
253
+ # not readable — nothing to say either way
147
254
  nil
148
255
  end
149
256
  end
@@ -186,6 +293,33 @@ module GraphWeaver
186
293
 
187
294
  def auth_var = options[:auth] || GraphWeaver::SchemaLoader::DEFAULT_AUTH_ENV
188
295
 
296
+ # The dump is the one file the generator doesn't write through
297
+ # create_file, so Thor can't prompt on it — declining every conflict on
298
+ # a re-run still replaced it, and with it the source url it records.
299
+ # It is never overwritten here: `schema:refresh` is the command for
300
+ # that, and it re-fetches in place without touching anything else.
301
+ # True when there is one, having said so.
302
+ def keep_existing_dump
303
+ path = GraphWeaver::SchemaLoader.locate_path or return false
304
+
305
+ recorded = GraphWeaver::SchemaLoader.provenance(path)&.dig("url")
306
+ # a re-run naming a different endpoint would otherwise be answered
307
+ # silently by the dump the old one left
308
+ from = " (introspected from #{recorded})" if recorded && recorded != source
309
+ say_status :keep, "#{GraphWeaver::Internal::Util.relative(path)}#{from} — " \
310
+ "delete it and re-run to re-introspect", :yellow
311
+ true
312
+ end
313
+
314
+ # --auth is what says this API takes a token. Without it the line is
315
+ # shown rather than wired: a public API's initializer shouldn't read an
316
+ # ENV var nobody set, and the commented line is how you add one later.
317
+ def auth_setting
318
+ return %(auth: ENV["#{auth_var}"],) if options[:auth]
319
+
320
+ %(# auth: ENV["#{auth_var}"], # uncomment when the API needs a token)
321
+ end
322
+
189
323
  # The command just typed, retyped. One rule for every source form, and
190
324
  # the only one that always works: the files already written come back
191
325
  # "identical", and --auth rides along — where schema:refresh has no flag
@@ -202,7 +336,7 @@ module GraphWeaver
202
336
  # Custom scalars, enums and type mixins go here — `rake graph_weaver:generate`
203
337
  # bakes them into the generated source, so they must be registered first:
204
338
  #
205
- # GraphWeaver.register_scalar("DateTime", Time, serialize: :iso8601, requires: "time")
339
+ # GraphWeaver.register_scalar("Money", BigDecimal)
206
340
  # GraphWeaver.extend_type("Person", Greetable)
207
341
  RUBY
208
342
  end
@@ -215,7 +349,7 @@ module GraphWeaver
215
349
  <<~RUBY
216
350
  GraphWeaver.client = GraphWeaver.new(
217
351
  "#{source}",
218
- auth: ENV["#{auth_var}"],
352
+ #{auth_setting}
219
353
  cache: true, # reuse the committed dump; delete it to re-introspect
220
354
  )
221
355
  RUBY
@@ -45,7 +45,7 @@ class GraphWeaver::Client
45
45
  private_constant :CONTEXT_IN_PROCESS, :RETRY_RULE
46
46
 
47
47
  def initialize(source, auth: nil, headers: {}, transport: nil, cache: nil, ttl: nil,
48
- open_timeout: nil, read_timeout: nil, context: nil,
48
+ open_timeout: nil, read_timeout: nil, pool_size: nil, context: nil,
49
49
  retries: false, backoff: nil, base_delay: nil, max_delay: nil, jitter: nil, retry_on: nil,
50
50
  retry_if: nil, retry_codes: nil, retry_mutations: nil, sleeper: nil, &middleware)
51
51
  check_source!(source)
@@ -58,10 +58,11 @@ class GraphWeaver::Client
58
58
  if source.is_a?(String) && source.match?(URL)
59
59
  raise ArgumentError, CONTEXT_IN_PROCESS if context
60
60
 
61
- built = build_transport(source, auth:, headers:, kind: transport, open_timeout:, read_timeout:, &middleware)
61
+ built = build_transport(source, auth:, headers:, kind: transport, open_timeout:, read_timeout:, pool_size:,
62
+ &middleware)
62
63
  @transport = wrap_retries(built, retries, retry_options)
63
64
  else
64
- if auth || middleware || retries || open_timeout || read_timeout || !retry_options.empty?
65
+ if auth || middleware || retries || open_timeout || read_timeout || pool_size || !retry_options.empty?
65
66
  raise ArgumentError, "auth:/retries:/timeouts/middleware apply to a url — got a schema source"
66
67
  end
67
68
  if transport.is_a?(Symbol)
@@ -94,6 +95,27 @@ class GraphWeaver::Client
94
95
  @schema_lock = Mutex.new
95
96
  end
96
97
 
98
+ # Called by generated code — not semver'd for direct use.
99
+ #
100
+ # What actually runs a request, for anything in a client slot. A bare
101
+ # graphql-ruby schema class satisfies the execute contract on its own, so
102
+ # `client "Billing::Schema"`, `GraphWeaver.client = MyApp::Schema` and
103
+ # `execute!(client: MyApp::Schema)` all worked — and every one of them ran
104
+ # with no instrumentation seam at all: Schema.execute is not ours to
105
+ # bracket, so there was no APM event and no log line, not even at debug.
106
+ # One rule, applied wherever a client is read: a schema class gets the same
107
+ # InProcess wrapper GraphWeaver.new(Schema) builds. Everything else — a
108
+ # Client, a transport, a Retry, a fake — passes through untouched.
109
+ #
110
+ # Not memoized: the wrapper is two ivars beside a whole GraphQL execution,
111
+ # and in dev the class object is replaced on reload, so anything held onto
112
+ # would be the stale one.
113
+ def self.instrumented(client)
114
+ return client unless client.is_a?(Class) && client <= GraphQL::Schema
115
+
116
+ GraphWeaver::InProcess.new(client)
117
+ end
118
+
97
119
  # The transport queries run through: a url-built transport, an
98
120
  # explicit transport:, or the live schema class executing in-process.
99
121
  # Clients are self-contained — the app default never leaks in; nil for
@@ -106,6 +128,17 @@ class GraphWeaver::Client
106
128
  "this client has no transport (built from a schema dump) — pass a url or transport:"
107
129
  end
108
130
 
131
+ # How long a failed introspection answers for the threads behind it. The
132
+ # lock makes a cold schema one round trip at a time, so against a hung
133
+ # upstream every queued thread used to pay its own read_timeout in turn —
134
+ # 8 threads at the 30s default is four minutes of occupied worker, and the
135
+ # next wave paid it again. A second is enough to collapse a wave and the
136
+ # retry right behind it, and short enough that an upstream which comes back
137
+ # is tried again on the next request. Deliberately not a circuit breaker:
138
+ # nothing here counts failures or stays open.
139
+ FAILURE_TTL = 1.0
140
+ private_constant :FAILURE_TTL
141
+
109
142
  # The schema, introspecting through the transport on first use (cached
110
143
  # per the client's cache:/ttl:) unless one was given up front.
111
144
  #
@@ -114,7 +147,17 @@ class GraphWeaver::Client
114
147
  # in-flight thread, each of them also writing the cache file.
115
148
  def schema
116
149
  @schema_lock.synchronize do
117
- @schema ||= GraphWeaver::SchemaLoader.introspect(transport!, cache: @cache, ttl: @ttl)
150
+ next @schema if @schema
151
+ raise @schema_error if @schema_error && Process.clock_gettime(Process::CLOCK_MONOTONIC) < @schema_error_until
152
+
153
+ begin
154
+ @schema_error = nil
155
+ @schema = GraphWeaver::SchemaLoader.introspect(transport!, cache: @cache, ttl: @ttl)
156
+ rescue GraphWeaver::Error => e
157
+ @schema_error = e
158
+ @schema_error_until = Process.clock_gettime(Process::CLOCK_MONOTONIC) + FAILURE_TTL
159
+ raise
160
+ end
118
161
  end
119
162
  end
120
163
 
@@ -165,15 +208,18 @@ class GraphWeaver::Client
165
208
  # it lets an unrelated gem swap your transport — along with its
166
209
  # timeouts and, since Faraday's default net_http adapter reconnects
167
210
  # per request, your connection reuse. Same code, same transport.
168
- def build_transport(url, auth:, headers:, kind:, open_timeout: nil, read_timeout: nil, &middleware)
211
+ def build_transport(url, auth:, headers:, kind:, open_timeout: nil, read_timeout: nil, pool_size: nil, &middleware)
169
212
  headers = headers.dup
170
213
  if auth
171
- unless auth.is_a?(String)
172
- raise ArgumentError, "auth: takes a token string, got #{auth.class} other headers go in " \
173
- "headers:, and a token that rotates goes in the Faraday middleware block"
214
+ unless auth.is_a?(String) || auth.respond_to?(:call)
215
+ raise ArgumentError, "auth: takes a token string, or something answering #call that returns " \
216
+ "one per request, got #{auth.class} other headers go in headers:"
174
217
  end
175
218
 
176
- headers["Authorization"] ||= auth.include?(" ") ? auth : "Bearer #{auth}"
219
+ # a callable stays callable: both transports resolve a header value per
220
+ # request, which is what a token that expires needs
221
+ headers["Authorization"] ||=
222
+ auth.respond_to?(:call) ? -> { bearer(auth.call) } : bearer(auth)
177
223
  end
178
224
 
179
225
  # nil means "the transport's default" — both bundled ones agree on it
@@ -181,15 +227,27 @@ class GraphWeaver::Client
181
227
 
182
228
  transport =
183
229
  if transport_kind(kind, middleware) == :faraday
230
+ # Faraday's adapter owns its connections; a pool ceiling here would be
231
+ # a number nothing reads, so say so instead of dropping it
232
+ raise ArgumentError, "pool_size: sizes the bundled HTTP transport's pool — Faraday's adapter " \
233
+ "manages its own connections, so configure it there" if pool_size
184
234
  build_faraday(url, headers:, timeouts:, &middleware)
185
235
  else
186
- GraphWeaver::Transport::HTTP.new(url, headers:, **timeouts)
236
+ GraphWeaver::Transport::HTTP.new(url, headers:, pool_size:, **timeouts)
187
237
  end
188
238
 
189
- GraphWeaver::Internal::Log.log(:info) { "transport: #{transport.class} -> #{url}" }
239
+ GraphWeaver::Internal::Log.log(:info) { "transport: #{transport.class} -> #{transport.safe_url}" }
190
240
  transport
191
241
  end
192
242
 
243
+ # "Bearer" is assumed unless the token carries its own scheme; nil is a
244
+ # token the caller declined to produce, and drops the header.
245
+ def bearer(token)
246
+ return if token.nil?
247
+
248
+ token.to_s.include?(" ") ? token.to_s : "Bearer #{token}"
249
+ end
250
+
193
251
  # Which bundled transport a url client builds: the explicit
194
252
  # transport:, else Faraday when a middleware block asks for it.
195
253
  def transport_kind(kind, middleware)
@@ -62,7 +62,7 @@ class GraphWeaver::Codegen
62
62
  # than the file.
63
63
  def check_alias_name!(node, name)
64
64
  taken = node.fields.any? { |f| f.prop == name } ||
65
- STRUCT_METHODS.include?(name) || ALIAS_RESERVED.include?(name) ||
65
+ RESERVED_PROPS.include?(name) || ALIAS_RESERVED.include?(name) ||
66
66
  RUBY_KEYWORDS.include?(name)
67
67
  return unless taken
68
68
 
@@ -72,10 +72,10 @@ class GraphWeaver::Codegen
72
72
 
73
73
  # Registered aliases for a GraphQL type (see extend_type alias:).
74
74
  def type_aliases(graphql_name)
75
- GraphWeaver::Codegen.type_registry[graphql_name]&.dig(:aliases) || {}
75
+ @registry.type_registry[graphql_name]&.dig(:aliases) || {}
76
76
  end
77
77
 
78
- # The CLASS methods a generated struct defines; STRUCT_METHODS covers the
78
+ # The CLASS methods a generated struct defines; RESERVED_PROPS covers the
79
79
  # instance side, and both are checked with RUBY_KEYWORDS alongside (all
80
80
  # three are defined by the class this mixes into).
81
81
  ALIAS_RESERVED = %w[from_h].to_set.freeze
@@ -163,10 +163,12 @@ class GraphWeaver::Codegen
163
163
  type = obj.graphql_type && @schema.get_type(obj.graphql_type)
164
164
  return unless type.respond_to?(:fields)
165
165
 
166
- known = type.fields.keys.map { |field| GraphWeaver::Inflect.underscore(field) }
166
+ # prop_name, not underscore: a path hops through PROPS, so a reserved
167
+ # field is spelled with its trailing underscore here too
168
+ known = type.fields.keys.map { |field| GraphWeaver::Codegen.prop_name(field) }
167
169
  return if seg == "__typename" || known.include?(seg)
168
170
 
169
- prop = GraphWeaver::Inflect.underscore(seg)
171
+ prop = GraphWeaver::Codegen.prop_name(seg)
170
172
  hint = if prop != seg && known.include?(prop)
171
173
  # paths are the Ruby prop chain, not the GraphQL one — the classic miss
172
174
  " — GraphQL fields generate snake_case props; use '#{prop}'"