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