graph_weaver 0.7.0 → 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 (55) 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 +370 -459
  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 +340 -486
  16. data/docs/transports.md +191 -263
  17. data/docs/upgrading.md +188 -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 +22 -1
  33. data/lib/graph_weaver/codegen.rb +5 -1
  34. data/lib/graph_weaver/context_seam.rb +54 -0
  35. data/lib/graph_weaver/errors.rb +23 -15
  36. data/lib/graph_weaver/federation.rb +11 -2
  37. data/lib/graph_weaver/in_process.rb +15 -9
  38. data/lib/graph_weaver/internal/endpoint.rb +7 -5
  39. data/lib/graph_weaver/internal/headers.rb +19 -0
  40. data/lib/graph_weaver/internal.rb +66 -13
  41. data/lib/graph_weaver/log_subscriber.rb +10 -2
  42. data/lib/graph_weaver/logging.rb +33 -13
  43. data/lib/graph_weaver/query_module.rb +8 -0
  44. data/lib/graph_weaver/retry.rb +12 -8
  45. data/lib/graph_weaver/schema_loader.rb +52 -14
  46. data/lib/graph_weaver/testing/cassette.rb +28 -5
  47. data/lib/graph_weaver/testing/endpoint.rb +14 -13
  48. data/lib/graph_weaver/testing/fake_client.rb +33 -3
  49. data/lib/graph_weaver/testing/router.rb +7 -3
  50. data/lib/graph_weaver/transport/http.rb +2 -2
  51. data/lib/graph_weaver/transport.rb +47 -23
  52. data/lib/graph_weaver/version.rb +1 -1
  53. data/lib/graph_weaver.rb +22 -1
  54. metadata +16 -3
  55. 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
  }
@@ -128,6 +128,17 @@ class GraphWeaver::Client
128
128
  "this client has no transport (built from a schema dump) — pass a url or transport:"
129
129
  end
130
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
+
131
142
  # The schema, introspecting through the transport on first use (cached
132
143
  # per the client's cache:/ttl:) unless one was given up front.
133
144
  #
@@ -136,7 +147,17 @@ class GraphWeaver::Client
136
147
  # in-flight thread, each of them also writing the cache file.
137
148
  def schema
138
149
  @schema_lock.synchronize do
139
- @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
140
161
  end
141
162
  end
142
163
 
@@ -1147,7 +1147,11 @@ class GraphWeaver::Codegen
1147
1147
  # about. Dispatch reads __typename, so the query must select it; for
1148
1148
  # interfaces the interface-level fields gather into every member.
1149
1149
  def union_members(type, selections)
1150
- raise ArgumentError, typename_refusal(type, selections) unless dispatchable_typename?(type, selections)
1150
+ unless dispatchable_typename?(type, selections)
1151
+ # a refusal about the query, like its siblings: branded and path-named,
1152
+ # so generate! can collect it and rake names the file
1153
+ raise GraphWeaver::Error, "#{@path ? "#{@path}: " : ""}#{typename_refusal(type, selections)}"
1154
+ end
1151
1155
 
1152
1156
  selected_members(type, selections).sort_by(&:graphql_name).to_h do |possible|
1153
1157
  [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)"
@@ -5,6 +5,7 @@ require "json"
5
5
 
6
6
  require_relative "errors"
7
7
  require_relative "internal"
8
+ require_relative "context_seam"
8
9
  require_relative "parsing"
9
10
  require_relative "transport"
10
11
 
@@ -32,13 +33,13 @@ require_relative "transport"
32
33
  # is usually the whole reason you're running in-process.
33
34
  class GraphWeaver::InProcess
34
35
  include GraphWeaver::Parsing
36
+ # #context/#context= plus the lock over them: Testing::Endpoint answers a
37
+ # `context:` proc from one request's headers by writing this field, so the
38
+ # field's owner owns the lock
39
+ include GraphWeaver::ContextSeam
35
40
 
36
- # the schema queries run against, and the context handed to every one
37
- attr_reader :schema, :context
38
-
39
- # settable so Testing::Endpoint can answer a `context:` proc from the
40
- # request's headers and put it back — the same seam Router#context= is
41
- attr_writer :context
41
+ # the schema queries run against
42
+ attr_reader :schema
42
43
 
43
44
  def initialize(schema, context: {})
44
45
  unless schema.respond_to?(:execute)
@@ -46,12 +47,13 @@ class GraphWeaver::InProcess
46
47
  end
47
48
 
48
49
  @schema = schema
49
- @context = context
50
+ init_context_seam(context)
50
51
  end
51
52
 
52
53
  def execute(query, variables: {}, operation_name: nil)
53
54
  operation_name ||= GraphWeaver::Internal::Wire.operation_name(query)
54
- payload = { url: nil, schema: schema_label, operation: operation_name, client: self.class }
55
+ payload = { url: nil, schema: schema_label, operation: operation_name, client: self.class,
56
+ kind: GraphWeaver::Internal::Wire.kind(query) }
55
57
 
56
58
  GraphWeaver::Internal::Log.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
57
59
  perform(query, variables, operation_name)
@@ -84,7 +86,11 @@ class GraphWeaver::InProcess
84
86
  # a resolver blew up. The same failure over HTTP arrives as a 500, so
85
87
  # raise what HTTP would — code that rescues GraphWeaver::Error, or
86
88
  # branches on ServerError#status, behaves the same either side.
87
- raise GraphWeaver::ServerError.new(status: 500, body: "#{e.class}: #{e.message}")
89
+ # detail:, not body: there was no response, so there are no bytes to
90
+ # hold, and the diagnosis is this process's own exception
91
+ raise GraphWeaver::ServerError.new(
92
+ status: 500, detail: "#{e.class}: #{GraphWeaver::Internal::Redact.cap(e.message)}",
93
+ )
88
94
  end
89
95
 
90
96
  # never leak the context (session tokens, current_user) through logs or
@@ -54,20 +54,22 @@ module GraphWeaver
54
54
  def drop_secrets(query)
55
55
  kept = query.split("&").reject do |pair|
56
56
  name, value = pair.split("=", 2)
57
- value && Redact.filtered?(URI.decode_www_form_component(name))
57
+ value && Redact.credential?(URI.decode_www_form_component(name))
58
58
  end
59
59
  kept.join("&") unless kept.empty?
60
60
  end
61
61
 
62
62
  # Which query parameters are secret is the same question
63
63
  # GraphWeaver.filter_parameters already answers for variables, so a
64
- # scrubbed log reads the same either side of the seam. Split rather
65
- # than decoded and re-encoded: every parameter that stays is printed
66
- # exactly as it was sent.
64
+ # scrubbed log reads the same either side of the seam widened by the
65
+ # default names, which apply here even when the app has emptied its
66
+ # list (see Redact.credential?). Split rather than decoded and
67
+ # re-encoded: every parameter that stays is printed exactly as it was
68
+ # sent.
67
69
  def scrub_query(query)
68
70
  query.split("&").map do |pair|
69
71
  name, value = pair.split("=", 2)
70
- next pair if value.nil? || !Redact.filtered?(URI.decode_www_form_component(name))
72
+ next pair if value.nil? || !Redact.credential?(URI.decode_www_form_component(name))
71
73
 
72
74
  "#{name}=#{GraphWeaver::FILTERED}"
73
75
  end.join("&")
@@ -1,6 +1,8 @@
1
1
  # typed: true
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "time" # Time.httpdate, for Retry-After
5
+
4
6
  module GraphWeaver
5
7
  module Internal
6
8
  # Response headers, as ServerError carries them. HTTP field names are
@@ -42,6 +44,23 @@ module GraphWeaver
42
44
  rest.empty? ? value : value&.dig(*rest)
43
45
  end
44
46
 
47
+ # Seconds to wait per the server's Retry-After, which is either a delay
48
+ # in seconds or an HTTP-date. nil when absent or unparseable; a date
49
+ # already past clamps to 0. See RFC 9110 §10.2.3.
50
+ #
51
+ # Here rather than on ServerError because a rate limit reaches a caller
52
+ # two ways — raised, and returned as the envelope a 4xx/5xx WITH a
53
+ # GraphQL errors body makes — and both have to read the one rule.
54
+ def retry_after
55
+ value = self["retry-after"]&.strip
56
+ return if value.nil? || value.empty?
57
+ return value.to_f if value.match?(/\A\d+(\.\d+)?\z/)
58
+
59
+ [Time.httpdate(value) - Time.now, 0.0].max
60
+ rescue ArgumentError
61
+ nil
62
+ end
63
+
45
64
  def key?(name) = super(Headers.fold(name))
46
65
  alias_method :has_key?, :key?
47
66
  alias_method :include?, :key?
@@ -52,19 +52,29 @@ module GraphWeaver
52
52
 
53
53
  # The module a .graphql file generates, and the basename of the file
54
54
  # it generates into: the camelized file name plus the operation's own
55
- # word.
55
+ # word. Every run of non-alphanumerics in the name is a word boundary,
56
+ # and a trailing extension naming the document's own operation kind is
57
+ # dropped rather than doubled.
56
58
  #
57
59
  # person.graphql => PersonQuery (person_query.rb)
58
60
  # save_list_entry.graphql => SaveListEntryMutation
59
61
  # (save_list_entry_mutation.rb)
62
+ # get-hello.graphql => GetHelloQuery (get_hello_query.rb)
63
+ # hello.query.graphql => HelloQuery (hello_query.rb)
60
64
  #
61
65
  # Every naming site goes through here — generate!, parse(path), and
62
66
  # load_queries! — so the constant a file produces is the same one
63
67
  # whichever door you came in by, and the file it lands in matches it.
64
68
  def generated_names(path, source)
65
- base = File.basename(path, ".*")
66
- suffix = operation_suffix(source)
67
- ["#{Inflect.camelize(base)}#{suffix}", "#{base}_#{suffix.downcase}.rb"]
69
+ kind = operation_kind(source)
70
+ base = strip_kind_extension(File.basename(path, ".*"), kind, path)
71
+ stem = base.gsub(/[^A-Za-z0-9]+/, "_")
72
+ suffix = (kind == "mutation") ? "Mutation" : "Query"
73
+ name = Inflect.camelize(stem)
74
+ # all punctuation camelizes to nothing, which would leave the suffix
75
+ # standing alone as the whole name — keep the base so it stays refusable
76
+ name = base if name.empty?
77
+ ["#{name}#{suffix}", "#{stem}_#{suffix.downcase}.rb"]
68
78
  end
69
79
 
70
80
  # just the module name — see generated_names
@@ -240,13 +250,36 @@ module GraphWeaver
240
250
  source
241
251
  end
242
252
 
243
- # "Mutation" for a mutation document, "Query" for everything else.
244
- def operation_suffix(source)
253
+ # The document's operation kind — "query", "mutation" or
254
+ # "subscription" — or nil when it holds no operation or won't parse.
255
+ # The one source of truth for the word a module name ends in AND for
256
+ # the file-name extension that word makes redundant.
257
+ def operation_kind(source)
245
258
  operation = GraphQL.parse(source).definitions
246
259
  .grep(GraphQL::Language::Nodes::OperationDefinition).first
247
- (operation&.operation_type == "mutation") ? "Mutation" : "Query"
260
+ operation && (operation.operation_type || "query") # `{ hello }` is shorthand for a query
248
261
  rescue GraphQL::ParseError
249
- "Query" # unparseable: codegen brands the real error a moment later
262
+ nil # unparseable: codegen brands the real error a moment later
263
+ end
264
+
265
+ # GraphQL's operation kinds, as a file name spells them. Apollo, Relay
266
+ # and GitLab's frontend all name a query file for its operation, so
267
+ # `blob_content.query.graphql` says in the extension exactly what the
268
+ # module's own suffix says — drop it rather than emit BlobContentQueryQuery.
269
+ # `_query` inside a snake_case name is a word OF the name, not this, so
270
+ # nothing that generates today is renamed.
271
+ OPERATION_EXTENSION = /\.(query|mutation|subscription)\z/i
272
+ private_constant :OPERATION_EXTENSION
273
+
274
+ def strip_kind_extension(base, kind, path)
275
+ declared = base[OPERATION_EXTENSION, 1]&.downcase
276
+ stem = declared && base[0...-(declared.length + 1)]
277
+ return base if stem.nil? || stem.empty?
278
+ return stem if kind.nil? || kind == declared
279
+
280
+ raise GraphWeaver::Error, "#{relative(path)}: the file name ends .#{declared}, but the " \
281
+ "document defines a #{kind} — rename it #{stem}.#{kind}#{File.extname(path)} " \
282
+ "or drop the .#{declared}"
250
283
  end
251
284
  end
252
285
  end
@@ -350,7 +383,8 @@ module GraphWeaver
350
383
  # every request, and the only way to be wrong (a field literally named
351
384
  # `mutation` opening a line) errs toward not retrying.
352
385
  MUTATION_PATTERN = /^[ \t]*mutation\b/
353
- private_constant :MUTATION_PATTERN
386
+ SUBSCRIPTION_PATTERN = /^[ \t]*subscription\b/
387
+ private_constant :MUTATION_PATTERN, :SUBSCRIPTION_PATTERN
354
388
 
355
389
  REQUEST_MUTEX = Mutex.new
356
390
  private_constant :REQUEST_MUTEX
@@ -365,16 +399,35 @@ module GraphWeaver
365
399
 
366
400
  def mutation?(query) = MUTATION_PATTERN.match?(query)
367
401
 
402
+ # What this document runs, for the instrumentation payload — :query
403
+ # for the shorthand `{ ... }` document too, which is what it is.
404
+ # Built on mutation? rather than beside it, so an APM's write-failure
405
+ # rate and the decision not to retry can't come to disagree.
406
+ def kind(query)
407
+ return :mutation if mutation?(query)
408
+
409
+ SUBSCRIPTION_PATTERN.match?(query) ? :subscription : :query
410
+ end
411
+
368
412
  # one error in the shape a GraphQL response carries them
369
413
  def graphql_error(message, code)
370
414
  { "message" => message, "extensions" => { "code" => code } }
371
415
  end
372
416
 
373
- # "[req 3 FilteredPokemon]" — a per-process request id plus the
374
- # operation name, when there is one
417
+ # "[req 4123-3 FilteredPokemon]" — the pid, this process's own
418
+ # request count, and the operation name when there is one.
419
+ #
420
+ # Both halves, because a Puma cluster forks: the counter is inherited
421
+ # with everything else, so without the reset every worker continues
422
+ # the master's sequence, and without the pid two workers' "[req 3]"
423
+ # are two unrelated requests in one aggregated log.
375
424
  def log_tag(operation_name = nil)
376
- id = REQUEST_MUTEX.synchronize { @request_count = (@request_count || 0) + 1 }
377
- "[req #{id}#{" #{operation_name}" if operation_name}]"
425
+ pid = Process.pid
426
+ id = REQUEST_MUTEX.synchronize do
427
+ @request_pid, @request_count = pid, 0 unless @request_pid == pid
428
+ @request_count += 1
429
+ end
430
+ "[req #{pid}-#{id}#{" #{operation_name}" if operation_name}]"
378
431
  end
379
432
 
380
433
  def truncate_for_log(query)