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.
- 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 +380 -463
- 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 +343 -486
- data/docs/transports.md +203 -268
- data/docs/upgrading.md +211 -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 +30 -1
- data/lib/graph_weaver/codegen/emit.rb +5 -11
- data/lib/graph_weaver/codegen.rb +23 -55
- 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/graph.rb +39 -29
- 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/test_clients.rb +7 -11
- data/lib/graph_weaver/internal.rb +81 -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 +44 -23
- data/lib/graph_weaver/retry.rb +12 -8
- data/lib/graph_weaver/rspec.rb +13 -24
- data/lib/graph_weaver/schema_loader.rb +52 -14
- data/lib/graph_weaver/tasks.rb +10 -2
- 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/testing.rb +12 -4
- 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 +32 -10
- 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
|
@@ -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
|
|
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
|
|
460
|
-
out << " # client
|
|
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 —
|
|
465
|
-
out << " # its stand-in
|
|
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 :
|
|
468
|
+
out << " private_constant :GRAPH"
|
|
475
469
|
end
|
|
476
470
|
out << ""
|
|
477
471
|
|
data/lib/graph_weaver/codegen.rb
CHANGED
|
@@ -59,15 +59,10 @@ class GraphWeaver::Codegen
|
|
|
59
59
|
|
|
60
60
|
attr_reader :name
|
|
61
61
|
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
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,
|
|
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
|
-
|
|
100
|
-
#
|
|
101
|
-
#
|
|
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,
|
|
146
|
-
new(schema:, query:, name:,
|
|
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:
|
|
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
|
-
|
|
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
|
-
#
|
|
171
|
-
#
|
|
172
|
-
|
|
133
|
+
# a parsed module generates no file, so it has no graph to read a client
|
|
134
|
+
# off — client: 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
|
-
|
|
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
|
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)"
|