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.
- checksums.yaml +4 -4
- data/Gemfile.lock +4 -4
- data/README.md +40 -88
- data/docs/alternatives.md +1 -7
- data/docs/cassettes.md +54 -59
- data/docs/editors.md +32 -47
- data/docs/errors.md +261 -369
- data/docs/federation.md +650 -837
- data/docs/generated_modules.md +370 -459
- data/docs/getting_started.md +211 -428
- data/docs/i18n.md +114 -177
- data/docs/logging.md +127 -116
- data/docs/real_world.md +26 -39
- data/docs/scalars.md +277 -310
- data/docs/testing.md +340 -486
- data/docs/transports.md +191 -263
- data/docs/upgrading.md +188 -560
- data/examples/README.md +38 -0
- data/examples/countries.rb +39 -0
- data/examples/federation.rb +62 -0
- data/examples/github/generate.rb +20 -0
- data/examples/github/generated/star_mutation.rb +126 -0
- data/examples/github/generated/stargazers_query.rb +232 -0
- data/examples/github/generated/starred_query.rb +151 -0
- data/examples/github/queries/star.graphql +8 -0
- data/examples/github/queries/stargazers.graphql +22 -0
- data/examples/github/queries/starred.graphql +11 -0
- data/examples/github/run.rb +43 -0
- data/examples/github/setup.rb +18 -0
- data/examples/rick_and_morty.rb +57 -0
- data/graph_weaver.gemspec +12 -3
- data/lib/graph_weaver/client.rb +22 -1
- data/lib/graph_weaver/codegen.rb +5 -1
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +23 -15
- data/lib/graph_weaver/federation.rb +11 -2
- data/lib/graph_weaver/in_process.rb +15 -9
- data/lib/graph_weaver/internal/endpoint.rb +7 -5
- data/lib/graph_weaver/internal/headers.rb +19 -0
- data/lib/graph_weaver/internal.rb +66 -13
- data/lib/graph_weaver/log_subscriber.rb +10 -2
- data/lib/graph_weaver/logging.rb +33 -13
- data/lib/graph_weaver/query_module.rb +8 -0
- data/lib/graph_weaver/retry.rb +12 -8
- data/lib/graph_weaver/schema_loader.rb +52 -14
- data/lib/graph_weaver/testing/cassette.rb +28 -5
- data/lib/graph_weaver/testing/endpoint.rb +14 -13
- data/lib/graph_weaver/testing/fake_client.rb +33 -3
- data/lib/graph_weaver/testing/router.rb +7 -3
- data/lib/graph_weaver/transport/http.rb +2 -2
- data/lib/graph_weaver/transport.rb +47 -23
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +22 -1
- metadata +16 -3
- 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,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
|
-
|
|
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'
|
|
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
|
-
|
|
30
|
+
# pinned to the release tag, not main — CHANGELOG.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
|
}
|
data/lib/graph_weaver/client.rb
CHANGED
|
@@ -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
|
|
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
|
|
data/lib/graph_weaver/codegen.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/graph_weaver/errors.rb
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
136
|
-
|
|
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,
|
|
156
|
-
#
|
|
157
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
37
|
-
attr_reader :schema
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
65
|
-
#
|
|
66
|
-
#
|
|
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.
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
#
|
|
244
|
-
|
|
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
|
|
260
|
+
operation && (operation.operation_type || "query") # `{ hello }` is shorthand for a query
|
|
248
261
|
rescue GraphQL::ParseError
|
|
249
|
-
|
|
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
|
-
|
|
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]" —
|
|
374
|
-
# operation name
|
|
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
|
-
|
|
377
|
-
|
|
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)
|