graph_weaver 0.7.0 → 0.7.2

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +4 -4
  3. data/README.md +40 -88
  4. data/docs/alternatives.md +1 -7
  5. data/docs/cassettes.md +54 -59
  6. data/docs/editors.md +32 -47
  7. data/docs/errors.md +261 -369
  8. data/docs/federation.md +650 -837
  9. data/docs/generated_modules.md +380 -463
  10. data/docs/getting_started.md +211 -428
  11. data/docs/i18n.md +114 -177
  12. data/docs/logging.md +127 -116
  13. data/docs/real_world.md +26 -39
  14. data/docs/scalars.md +277 -310
  15. data/docs/testing.md +343 -486
  16. data/docs/transports.md +203 -268
  17. data/docs/upgrading.md +211 -560
  18. data/examples/README.md +38 -0
  19. data/examples/countries.rb +39 -0
  20. data/examples/federation.rb +62 -0
  21. data/examples/github/generate.rb +20 -0
  22. data/examples/github/generated/star_mutation.rb +126 -0
  23. data/examples/github/generated/stargazers_query.rb +232 -0
  24. data/examples/github/generated/starred_query.rb +151 -0
  25. data/examples/github/queries/star.graphql +8 -0
  26. data/examples/github/queries/stargazers.graphql +22 -0
  27. data/examples/github/queries/starred.graphql +11 -0
  28. data/examples/github/run.rb +43 -0
  29. data/examples/github/setup.rb +18 -0
  30. data/examples/rick_and_morty.rb +57 -0
  31. data/graph_weaver.gemspec +12 -3
  32. data/lib/graph_weaver/client.rb +30 -1
  33. data/lib/graph_weaver/codegen/emit.rb +5 -11
  34. data/lib/graph_weaver/codegen.rb +23 -55
  35. data/lib/graph_weaver/context_seam.rb +54 -0
  36. data/lib/graph_weaver/errors.rb +23 -15
  37. data/lib/graph_weaver/federation.rb +11 -2
  38. data/lib/graph_weaver/graph.rb +39 -29
  39. data/lib/graph_weaver/in_process.rb +15 -9
  40. data/lib/graph_weaver/internal/endpoint.rb +7 -5
  41. data/lib/graph_weaver/internal/headers.rb +19 -0
  42. data/lib/graph_weaver/internal/test_clients.rb +7 -11
  43. data/lib/graph_weaver/internal.rb +81 -13
  44. data/lib/graph_weaver/log_subscriber.rb +10 -2
  45. data/lib/graph_weaver/logging.rb +33 -13
  46. data/lib/graph_weaver/query_module.rb +44 -23
  47. data/lib/graph_weaver/retry.rb +12 -8
  48. data/lib/graph_weaver/rspec.rb +13 -24
  49. data/lib/graph_weaver/schema_loader.rb +52 -14
  50. data/lib/graph_weaver/tasks.rb +10 -2
  51. data/lib/graph_weaver/testing/cassette.rb +28 -5
  52. data/lib/graph_weaver/testing/endpoint.rb +14 -13
  53. data/lib/graph_weaver/testing/fake_client.rb +33 -3
  54. data/lib/graph_weaver/testing/router.rb +7 -3
  55. data/lib/graph_weaver/testing.rb +12 -4
  56. data/lib/graph_weaver/transport/http.rb +2 -2
  57. data/lib/graph_weaver/transport.rb +47 -23
  58. data/lib/graph_weaver/version.rb +1 -1
  59. data/lib/graph_weaver.rb +32 -10
  60. metadata +16 -3
  61. data/CHANGELOG.md +0 -3801
@@ -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
  }
@@ -77,6 +77,10 @@ class GraphWeaver::Client
77
77
  # a live schema class doubles as an in-process transport; a loaded
78
78
  # dump has no resolvers, so it is type information only
79
79
  @schema = source.is_a?(Module) ? source : GraphWeaver::SchemaLoader.load(source)
80
+ # A supergraph's routing table lives in the file, not in the loaded
81
+ # schema, so the path is the only thing that can name one later. Told
82
+ # from SDL by its extension, as SchemaLoader tells it.
83
+ @schema_source = source if !source.is_a?(Module) && GraphWeaver::SchemaLoader.dump_path?(source)
80
84
 
81
85
  if context && !(source.is_a?(Module) && transport.nil?)
82
86
  # nothing would ever read it — a dump has no resolvers, and an
@@ -122,12 +126,27 @@ class GraphWeaver::Client
122
126
  # schema-dump clients (type information only).
123
127
  attr_reader :transport
124
128
 
129
+ # The dump this client's schema was read from, or nil for a url, a schema
130
+ # class, or inline SDL. What a graph named by this client is named by.
131
+ attr_reader :schema_source
132
+
125
133
  # transport, when this client must be able to execute
126
134
  private def transport!
127
135
  transport or raise GraphWeaver::Error,
128
136
  "this client has no transport (built from a schema dump) — pass a url or transport:"
129
137
  end
130
138
 
139
+ # How long a failed introspection answers for the threads behind it. The
140
+ # lock makes a cold schema one round trip at a time, so against a hung
141
+ # upstream every queued thread used to pay its own read_timeout in turn —
142
+ # 8 threads at the 30s default is four minutes of occupied worker, and the
143
+ # next wave paid it again. A second is enough to collapse a wave and the
144
+ # retry right behind it, and short enough that an upstream which comes back
145
+ # is tried again on the next request. Deliberately not a circuit breaker:
146
+ # nothing here counts failures or stays open.
147
+ FAILURE_TTL = 1.0
148
+ private_constant :FAILURE_TTL
149
+
131
150
  # The schema, introspecting through the transport on first use (cached
132
151
  # per the client's cache:/ttl:) unless one was given up front.
133
152
  #
@@ -136,7 +155,17 @@ class GraphWeaver::Client
136
155
  # in-flight thread, each of them also writing the cache file.
137
156
  def schema
138
157
  @schema_lock.synchronize do
139
- @schema ||= GraphWeaver::SchemaLoader.introspect(transport!, cache: @cache, ttl: @ttl)
158
+ next @schema if @schema
159
+ raise @schema_error if @schema_error && Process.clock_gettime(Process::CLOCK_MONOTONIC) < @schema_error_until
160
+
161
+ begin
162
+ @schema_error = nil
163
+ @schema = GraphWeaver::SchemaLoader.introspect(transport!, cache: @cache, ttl: @ttl)
164
+ rescue GraphWeaver::Error => e
165
+ @schema_error = e
166
+ @schema_error_until = Process.clock_gettime(Process::CLOCK_MONOTONIC) + FAILURE_TTL
167
+ raise
168
+ end
140
169
  end
141
170
  end
142
171
 
@@ -456,22 +456,16 @@ class GraphWeaver::Codegen
456
456
  end
457
457
 
458
458
  def emit_execute(out, variables)
459
- # client/client= carry no per-query types, so they live in the gem
460
- out << " # client / client= — see GraphWeaver::QueryModule"
459
+ # client carries no per-query types, so it lives in the gem
460
+ out << " # client — see GraphWeaver::QueryModule"
461
461
  out << " extend GraphWeaver::QueryModule"
462
462
  if @graph_name
463
463
  out << ""
464
- out << " # the graph this module was generated from — what a test mode builds"
465
- out << " # its stand-in client from"
464
+ out << " # the graph this module was generated from — whose client it runs"
465
+ out << " # against, and what a test mode builds its stand-in from"
466
466
  out << " GRAPH = T.let(#{@graph_name.inspect}, Symbol)"
467
- out << " private_constant :GRAPH"
468
- end
469
- if @client_const
470
- out << ""
471
- out << " # the baked default client, resolved on first use"
472
- out << " DEFAULT_CLIENT = T.let(-> { #{@client_const} }, T.proc.returns(T.untyped))"
473
467
  # QueryModule reads it with const_get, which privacy doesn't block
474
- out << " private_constant :DEFAULT_CLIENT"
468
+ out << " private_constant :GRAPH"
475
469
  end
476
470
  out << ""
477
471
 
@@ -59,15 +59,10 @@ class GraphWeaver::Codegen
59
59
 
60
60
  attr_reader :name
61
61
 
62
- # A client is anything responding to `execute(query, variables:)`
63
- # whose result `to_h`s into {"data" => ..., "errors" => ...} a
64
- # GraphWeaver::Client, a transport, a schema class, a fake.
65
- #
66
- # client: (a constant, or its name as a string) becomes the generated
67
- # module's baked default; when omitted, generated code falls back to
68
- # the app default (GraphWeaver.client=). graph_name: is the graph the
69
- # module belongs to, baked in so a test mode can build its stand-in from
70
- # the right schema. name: is the module the file
62
+ # graph_name: is the graph the module belongs to, baked in because it is
63
+ # the one thing a module can't be told at call time: it decides which
64
+ # client the module runs against (GraphWeaver::QueryModule) and which
65
+ # schema a test mode fabricates from. name: is the module the file
71
66
  # defines, defaulting to the operation's own name; default_name: is
72
67
  # parse's container-scoped fallback (file generation stays strict — a
73
68
  # checked-in file deserves a deliberate name). types_namespace: is the shared-types workflow (see
@@ -78,7 +73,7 @@ class GraphWeaver::Codegen
78
73
  # whole-union field spread as one of them resolves to a canonical type in the
79
74
  # shared module (see used_union_names). path: is the file the query was read
80
75
  # from, named alongside line and column in validation errors.
81
- def initialize(schema:, query:, name: nil, client: nil, default_name: nil,
76
+ def initialize(schema:, query:, name: nil, default_name: nil,
82
77
  types_namespace: nil, hoistable_unions: nil, path: nil, module_name: nil,
83
78
  graph_name: nil, registry: GraphWeaver::Codegen.registry)
84
79
  renamed!(module_name)
@@ -96,28 +91,10 @@ class GraphWeaver::Codegen
96
91
  @used_unions = []
97
92
  # scalars this generation had no registration for (see report_untyped_scalars)
98
93
  @untyped_scalars = []
99
- @client_const = CLIENT_CONST.call(client)
100
- # the graph this module belongs to, baked in beside the client: a test
101
- # mode builds its stand-in from the module's own schema, and only the
102
- # module can say whose that is (GraphWeaver::Internal::TestClients).
103
- # A Symbol, as GraphWeaver.graph makes it — the name is the identity.
94
+ # the graph this module belongs to: its client and, under a test mode,
95
+ # its stand-in are both read off it, and only the module can say whose
96
+ # it is. A Symbol, as GraphWeaver.graph makes it the name is the identity.
104
97
  @graph_name = graph_name&.to_sym
105
-
106
- if client && @client_const.nil?
107
- # a live object can't be spelled in generated source — parse can
108
- # set one via the module's writer, but file generation cannot
109
- raise ArgumentError, "client: must be a named constant or String (got #{client.inspect}) — " \
110
- "put the object in a constant and name it, client: \"MyApi::CLIENT\"; pass live objects to parse"
111
- end
112
- # The String is written into the module verbatim, so anything that isn't a
113
- # constant path emits source that doesn't parse. A url is the way to get
114
- # here — it is where the endpoint is spelled everywhere else — so the fix
115
- # names the value that was passed.
116
- if @client_const && !@client_const.match?(CONSTANT_NAME)
117
- raise ArgumentError, "client: #{@client_const.inspect} isn't a constant — generated source " \
118
- "spells this name, so it has to be one: CLIENT = GraphWeaver.new(#{@client_const.inspect}), " \
119
- "then client \"CLIENT\""
120
- end
121
98
  end
122
99
 
123
100
  # 0.5 spelled it module_name:, in two of the three doors. One knob, one
@@ -129,33 +106,19 @@ class GraphWeaver::Codegen
129
106
  end
130
107
  private :renamed!
131
108
 
132
- # The constant name a client can be referenced by in generated
133
- # source — nil when it can't be (live objects, anonymous modules).
134
- # A lambda rather than a method: both `parse` and `initialize` need it,
135
- # from the class and from an instance.
136
- CLIENT_CONST = lambda do |client|
137
- case client
138
- when String then client
139
- when Module then client.name
140
- end
141
- end
142
- private_constant :CLIENT_CONST
143
-
144
109
  # one-step shorthand
145
- def self.generate(schema:, query:, name: nil, client: nil, path: nil, module_name: nil)
146
- new(schema:, query:, name:, client:, path:, module_name:).generate
110
+ def self.generate(schema:, query:, name: nil, path: nil, module_name: nil)
111
+ new(schema:, query:, name:, path:, module_name:).generate
147
112
  end
148
113
 
149
114
  # Development convenience: generate + eval in one step, no build
150
115
  # artifact or checked-in file. Same runtime semantics as the generated
151
116
  # file, but invisible to srb tc — use the build step for static typing.
152
- # Evaluates into an anonymous container, so no global constants leak;
153
- # client: additionally accepts a live object (set via .client=).
117
+ # Evaluates into an anonymous container, so no global constants leak.
118
+ # client: is the client the parsed module runs against — it has no graph to
119
+ # read one off — and a per-call `client:` still wins over it.
154
120
  def self.parse(schema:, query:, name: nil, client: nil, path: nil, module_name: nil, graph_name: nil)
155
- client_const = CLIENT_CONST.call(client)
156
-
157
- codegen = new(schema:, query:, name:, client: client_const, path:, module_name:, graph_name:,
158
- default_name: "Query")
121
+ codegen = new(schema:, query:, name:, path:, module_name:, graph_name:, default_name: "Query")
159
122
  source = codegen.generate
160
123
 
161
124
  container = Module.new
@@ -167,9 +130,10 @@ class GraphWeaver::Codegen
167
130
  container.module_eval(source, "(graph_weaver)", 1)
168
131
  mod = container.const_get(codegen.name)
169
132
  GraphWeaver::Internal::Log.log(:debug) { "parsed #{codegen.name} (dynamic module, #{source.bytesize} bytes)" }
170
- # live objects (or anonymous modules) can't be referenced from
171
- # generated source set them via the module's writer instead
172
- mod.client = client if client && client_const.nil?
133
+ # a parsed module generates no file, so it has no graph to read a client
134
+ # offclient: binds one, whatever kind of object it is. The writer is
135
+ # private: parsing is the only thing that may bind one.
136
+ mod.send(:client=, client) if client
173
137
  mod
174
138
  end
175
139
 
@@ -1147,7 +1111,11 @@ class GraphWeaver::Codegen
1147
1111
  # about. Dispatch reads __typename, so the query must select it; for
1148
1112
  # interfaces the interface-level fields gather into every member.
1149
1113
  def union_members(type, selections)
1150
- raise ArgumentError, typename_refusal(type, selections) unless dispatchable_typename?(type, selections)
1114
+ unless dispatchable_typename?(type, selections)
1115
+ # a refusal about the query, like its siblings: branded and path-named,
1116
+ # so generate! can collect it and rake names the file
1117
+ raise GraphWeaver::Error, "#{@path ? "#{@path}: " : ""}#{typename_refusal(type, selections)}"
1118
+ end
1151
1119
 
1152
1120
  selected_members(type, selections).sort_by(&:graphql_name).to_h do |possible|
1153
1121
  [possible.graphql_name, object_node(possible, selections, camelize(possible.graphql_name))]
@@ -0,0 +1,54 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "monitor"
5
+
6
+ module GraphWeaver
7
+ # The settable GraphQL context, and the lock that guards it. {InProcess}
8
+ # and {Testing::Router} include it; so can any client of your own that
9
+ # wants the header seam.
10
+ #
11
+ # A `context:` proc is answered from one request's headers, which means
12
+ # assigning the client's context for the length of that dispatch — shared
13
+ # state, on the client. So the lock lives here, beside the field, rather
14
+ # than on whichever wrapper happens to be dispatching:
15
+ # {Testing::Endpoint} is built per request by `graphql: :wire`, and two of
16
+ # them over one client used to share nothing.
17
+ module ContextSeam
18
+ # the context handed to every query this client runs
19
+ attr_reader :context
20
+
21
+ def context=(value)
22
+ # Callability is settled by the field's owner, never by the
23
+ # per-request swap below — a resolved hash installed for one dispatch
24
+ # must not tell a concurrent one that this client has no seam.
25
+ @context_callable = value.respond_to?(:call)
26
+ @context = value
27
+ end
28
+
29
+ # Run the block with the context this request's headers mean — what
30
+ # {Testing::Endpoint} calls. A callable context is resolved and put back
31
+ # under the lock, so one request's identity can't leak into another's. A
32
+ # plain context has nothing to guard, so it is served concurrently.
33
+ def with_request_context(headers)
34
+ return yield unless @context_callable
35
+
36
+ @context_lock.synchronize do
37
+ seam = @context
38
+ @context = seam.call(headers)
39
+ begin
40
+ yield
41
+ ensure
42
+ @context = seam
43
+ end
44
+ end
45
+ end
46
+
47
+ # Call from your initializer. A Monitor rather than a Mutex: a resolver
48
+ # may re-enter the app.
49
+ private def init_context_seam(context)
50
+ @context_lock = Monitor.new
51
+ self.context = context
52
+ end
53
+ end
54
+ end
@@ -5,6 +5,7 @@ require "sorbet-runtime"
5
5
  require "time" # Time.httpdate, for Retry-After
6
6
 
7
7
  require_relative "inflect"
8
+ require_relative "internal/endpoint"
8
9
  require_relative "internal/headers"
9
10
  require_relative "logging"
10
11
 
@@ -125,15 +126,26 @@ module GraphWeaver
125
126
  attr_reader :url
126
127
 
127
128
  sig do
128
- params(status: Integer, body: T.untyped, headers: T::Hash[String, String], url: T.nilable(String)).void
129
+ params(
130
+ status: Integer,
131
+ body: T.untyped,
132
+ headers: T::Hash[String, String],
133
+ url: T.nilable(String),
134
+ detail: T.nilable(String),
135
+ ).void
129
136
  end
130
- def initialize(status:, body: nil, headers: {}, url: nil)
137
+ def initialize(status:, body: nil, headers: {}, url: nil, detail: nil)
131
138
  @status = status
132
139
  @body = body
133
140
  @url = url
134
141
  @headers = T.let(GraphWeaver::Internal::Headers.wrap(headers), T::Hash[String, String])
135
- snippet = body.to_s.empty? ? "" : ": #{body.to_s[0, 500]}"
136
- super("HTTP #{status}#{snippet}#{" #{hint}" if hint}#{" POST #{url}" if url}")
142
+ # A message never carries a body. `detail` is what WE say went wrong; the
143
+ # bytes the server sent stay on #body, the way #to_h already keeps the
144
+ # headers off — an error page that echoes the request (Rails' own dev
145
+ # page, many proxies) carries the caller's variables and our own
146
+ # Authorization header, and every raised error writes its message to the
147
+ # log at warn.
148
+ super("HTTP #{status}#{" — #{detail}" if detail}#{" — #{hint}" if hint}#{" — POST #{url}" if url}")
137
149
  end
138
150
 
139
151
  # What to do about this status, where the status says it. A redirect is
@@ -145,26 +157,22 @@ module GraphWeaver
145
157
  sig { returns(T.nilable(String)) }
146
158
  def hint
147
159
  if REDIRECTS.include?(status)
160
+ # the destination is a url the SERVER chose — said the way we say our
161
+ # own, and without the framing a header value could smuggle in
148
162
  location = headers["location"]
163
+ location &&= GraphWeaver::Internal::Redact.tag(GraphWeaver::Internal::Endpoint.safe(location))
149
164
  "redirects are not followed#{" — point the client at #{location}" if location}"
150
165
  elsif [401, 403].include?(status)
151
166
  "the server rejected the credentials — check auth: (the token, and its scopes)"
152
167
  end
153
168
  end
154
169
 
155
- # Seconds to wait per the server's Retry-After, which is either a
156
- # delay in seconds or an HTTP-date. nil when absent or unparseable;
157
- # negative dates (already past) clamp to 0. See RFC 9110 §10.2.3.
170
+ # Seconds to wait per the server's Retry-After, or nil. Parsed off the
171
+ # headers, which is also where a returned envelope reads it — so one rule
172
+ # answers a rate limit however it arrived.
158
173
  sig { returns(T.nilable(Float)) }
159
174
  def retry_after
160
- value = headers["retry-after"]&.strip
161
- return if value.nil? || value.empty?
162
- return value.to_f if value.match?(/\A\d+(\.\d+)?\z/)
163
-
164
- seconds = Time.httpdate(value) - Time.now
165
- [seconds, 0.0].max
166
- rescue ArgumentError
167
- nil
175
+ GraphWeaver::Internal::Headers.wrap(headers).retry_after
168
176
  end
169
177
 
170
178
  # True when the server said "you're going too fast" — 429, or the
@@ -142,7 +142,10 @@ module GraphWeaver
142
142
  def report
143
143
  return "#{@source} names no subgraphs" if @table.subgraphs.empty?
144
144
 
145
- [headline, *section(STALE, @stale), *shape_section, *section(UNCOMPOSED, @uncomposed),
145
+ # the scope caveat rides with the verdict that overclaims without it;
146
+ # a drift report is already telling you to recompose
147
+ [headline, *(NOT_COMPARED unless drift? || vacuous?),
148
+ *section(STALE, @stale), *shape_section, *section(UNCOMPOSED, @uncomposed),
146
149
  *skipped_section, *faked_section].join("\n")
147
150
  end
148
151
  alias to_s report
@@ -154,6 +157,12 @@ module GraphWeaver
154
157
 
155
158
  private
156
159
 
160
+ # A pass says the supergraph still describes the fields these schemas
161
+ # have — not that the next composition would succeed. Both categories
162
+ # below are composition's business, and this reads neither.
163
+ NOT_COMPARED = "not compared: @key (added, removed, or made unresolvable), and one field " \
164
+ "two subgraphs define without @shareable — recompose to catch those"
165
+
157
166
  STALE = "stale — the supergraph carries these, no schema here defines them (recompose):"
158
167
  SHAPE = "shape — both carry these, with different types (recompose):"
159
168
  UNCOMPOSED = "not composed in — a schema here defines these, the supergraph doesn't carry them:"
@@ -298,7 +307,7 @@ module GraphWeaver
298
307
  verdict =
299
308
  if counts.any? then counts.join(", ")
300
309
  elsif vacuous? then "compared against nothing here"
301
- else "matches the schemas here"
310
+ else "matches the schemas here, field for field and type for type"
302
311
  end
303
312
  "#{@source}: #{verdict} " \
304
313
  "(checked #{@checked.size} of #{@table.subgraphs.size} subgraphs)"