graph_weaver 0.4.6 → 0.5.0
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/CHANGELOG.md +1314 -0
- data/CLAUDE.md +100 -8
- data/DECISIONS.md +309 -0
- data/Gemfile.lock +23 -23
- data/NOTES.md +5 -5
- data/PLAN.md +106 -135
- data/README.md +115 -96
- data/REVIEW.md +946 -0
- data/docs/cassettes.md +75 -48
- data/docs/editors.md +82 -0
- data/docs/errors.md +32 -30
- data/docs/federation.md +520 -48
- data/docs/generated_modules.md +352 -137
- data/docs/getting_started.md +237 -67
- data/docs/logging.md +35 -6
- data/docs/real_world.md +21 -15
- data/docs/scalars.md +49 -154
- data/docs/testing.md +299 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +112 -0
- data/graph_weaver.gemspec +3 -1
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +114 -111
- data/lib/graph_weaver/codegen/aliases.rb +217 -0
- data/lib/graph_weaver/codegen/emit.rb +272 -258
- data/lib/graph_weaver/codegen/enum_type.rb +27 -124
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +593 -334
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +9 -1
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +14 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +67 -0
- data/lib/graph_weaver/query_module.rb +55 -0
- data/lib/graph_weaver/railtie.rb +23 -1
- data/lib/graph_weaver/representation.rb +74 -0
- data/lib/graph_weaver/response.rb +7 -0
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +794 -59
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +43 -8
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +160 -61
- data/lib/graph_weaver/testing/coverage.rb +165 -0
- data/lib/graph_weaver/testing/failure.rb +10 -23
- data/lib/graph_weaver/testing/fake_client.rb +181 -21
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1431 -0
- data/lib/graph_weaver/testing/subgraphs.rb +130 -0
- data/lib/graph_weaver/testing.rb +204 -14
- data/lib/graph_weaver/transport/faraday.rb +28 -10
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +67 -14
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +389 -170
- metadata +20 -3
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "graphql"
|
|
5
|
+
|
|
6
|
+
require_relative "../schema_loader"
|
|
7
|
+
require_relative "../schemas"
|
|
8
|
+
|
|
9
|
+
module GraphWeaver
|
|
10
|
+
module Testing
|
|
11
|
+
# Which Ruby schema serves which subgraph — for the subgraphs this
|
|
12
|
+
# process serves at all.
|
|
13
|
+
#
|
|
14
|
+
# You can name them yourself, but the map is boilerplate you then have to
|
|
15
|
+
# keep right — so by default they're **derived from what each schema
|
|
16
|
+
# defines**. A schema serves subgraph `s` when it defines every type and
|
|
17
|
+
# field the routing table says `s` resolves. That's evidence, not a
|
|
18
|
+
# guess: matching on class names would be one (`Accounts::Schema`,
|
|
19
|
+
# `AccountsSchema`, `Subgraphs::Accounts`), and a wrong guess points a
|
|
20
|
+
# suite at the wrong resolvers and still passes.
|
|
21
|
+
#
|
|
22
|
+
# Two matches refuse, naming both — both fit the evidence, so picking
|
|
23
|
+
# either would be the guess this module exists to avoid. **No match is
|
|
24
|
+
# not a refusal**: a supergraph is routinely only partly local, the rest
|
|
25
|
+
# served by another process, so a subgraph nothing here defines is left
|
|
26
|
+
# out of the map. Only a query that reaches its fields fails, at plan
|
|
27
|
+
# time — see {Router}.
|
|
28
|
+
#
|
|
29
|
+
# `"reviews" => :fake` asks for schema-correct fabricated data instead of
|
|
30
|
+
# that refusal (see {FakeSubgraph}).
|
|
31
|
+
#
|
|
32
|
+
# The same check runs over a map you pass explicitly, which is how a
|
|
33
|
+
# swapped pair fails at construction rather than as a mystery three
|
|
34
|
+
# fetches later.
|
|
35
|
+
module Subgraphs
|
|
36
|
+
# how many coordinates a message names before it says "and N more"
|
|
37
|
+
SAMPLE = 5
|
|
38
|
+
|
|
39
|
+
# answer this subgraph with fabricated data rather than refusing
|
|
40
|
+
FAKE = :fake
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# { "accounts" => Accounts::Schema, … } for the subgraphs this
|
|
44
|
+
# process serves — one nothing defines is absent, and left out.
|
|
45
|
+
# Names in `given` skip detection (:fake included); the rest are
|
|
46
|
+
# derived, and both go through the same check.
|
|
47
|
+
def resolve(table, given = nil, schemas: nil)
|
|
48
|
+
named = (given || {}).to_h { |name, schema| [name.to_s, schema] }
|
|
49
|
+
unknown = named.keys - table.subgraphs
|
|
50
|
+
if unknown.any?
|
|
51
|
+
raise GraphWeaver::ConfigurationError, "subgraphs: names #{unknown.join(", ")}, which " \
|
|
52
|
+
"this supergraph doesn't have (its subgraphs are #{table.subgraphs.join(", ")})"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
searched = schemas || GraphWeaver::Schemas.loaded
|
|
56
|
+
table.subgraphs.filter_map do |name|
|
|
57
|
+
served = named.key?(name) ? check!(table, name, named[name]) : detect(table, name, searched)
|
|
58
|
+
[name, served] if served
|
|
59
|
+
end.to_h
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# every loaded schema that defines what the table says `name` resolves
|
|
63
|
+
def candidates(table, name, schemas = GraphWeaver::Schemas.loaded)
|
|
64
|
+
schemas.select { |schema| missing(table, name, schema).empty? }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The schema coordinates the supergraph says `name` resolves — "Type"
|
|
68
|
+
# for one it declares, "Type.field" for one it answers. This is the
|
|
69
|
+
# evidence a match is judged on.
|
|
70
|
+
def expected(table, name)
|
|
71
|
+
table.types.flat_map do |type_name|
|
|
72
|
+
next [] unless table.declared_in(type_name).include?(name)
|
|
73
|
+
|
|
74
|
+
fields = table.fields(type_name).select { |field| table.owners(type_name, field).include?(name) }
|
|
75
|
+
[type_name] + fields.map { |field| "#{type_name}.#{field}" }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# which of them `schema` doesn't define
|
|
80
|
+
def missing(table, name, schema)
|
|
81
|
+
expected(table, name).reject { |coordinate| GraphWeaver::Schemas.defines?(schema, coordinate) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# The schema serving `name`, or nil when nothing here does — the
|
|
87
|
+
# subgraph is somebody else's, which is not an error until a query
|
|
88
|
+
# asks for it.
|
|
89
|
+
def detect(table, name, schemas)
|
|
90
|
+
found = candidates(table, name, schemas)
|
|
91
|
+
return found.first if found.one?
|
|
92
|
+
return if found.empty?
|
|
93
|
+
|
|
94
|
+
# by name: a dev reload leaves two class objects spelled the same,
|
|
95
|
+
# and naming one of them twice reads as a bug in the message
|
|
96
|
+
names = found.map(&:name).uniq.sort
|
|
97
|
+
raise GraphWeaver::ConfigurationError, "#{names.size} loaded schemas define everything the " \
|
|
98
|
+
"supergraph says #{name.inspect} resolves (#{names.join(", ")}) — pass subgraphs: naming " \
|
|
99
|
+
"the one you mean"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def check!(table, name, schema)
|
|
103
|
+
return FAKE if schema == FAKE
|
|
104
|
+
|
|
105
|
+
if schema.is_a?(Symbol)
|
|
106
|
+
raise GraphWeaver::ConfigurationError, "subgraphs[#{name.inspect}] is #{schema.inspect} — " \
|
|
107
|
+
"the only symbol an entry takes is #{FAKE.inspect}, which answers it with fabricated data"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
verify!(table, name, schema)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def verify!(table, name, schema)
|
|
114
|
+
gaps = missing(table, name, schema)
|
|
115
|
+
return schema if gaps.empty?
|
|
116
|
+
|
|
117
|
+
raise GraphWeaver::ConfigurationError, "subgraphs[#{name.inspect}] is " \
|
|
118
|
+
"#{schema.name || schema.inspect}, which doesn't define #{sample(gaps)} — the supergraph " \
|
|
119
|
+
"says #{name} resolves them. Did two entries get swapped?"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def sample(list)
|
|
123
|
+
return list.join(", ") if list.size <= SAMPLE
|
|
124
|
+
|
|
125
|
+
"#{list.first(SAMPLE).join(", ")} and #{list.size - SAMPLE} more"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
data/lib/graph_weaver/testing.rb
CHANGED
|
@@ -12,10 +12,16 @@ rescue LoadError
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
# Opt-in test tooling: require "graph_weaver/testing" from your spec
|
|
15
|
-
# helper (never from production code).
|
|
15
|
+
# helper (never from production code). Nothing here needs configuring —
|
|
16
|
+
# what a mode runs against is derived (see CLIENT_MODES). Configure to
|
|
17
|
+
# override a derivation, or to tune fabricated values:
|
|
16
18
|
#
|
|
17
19
|
# GraphWeaver::Testing.configure do |config|
|
|
18
|
-
# config.schema = MySchema #
|
|
20
|
+
# config.schema = MySchema # overrides the derived schema
|
|
21
|
+
# config.router = { subgraphs: { "reviews" => :fake } } # or supergraph:,
|
|
22
|
+
# # when it isn't the dump
|
|
23
|
+
# config.context = { current_user: } # baseline GraphQL context
|
|
24
|
+
# config.default_mode = :fake # untagged examples (graph_weaver/rspec)
|
|
19
25
|
# config.seed = 42 # reproducible fakes
|
|
20
26
|
# config.mode = :faker # or :literal; nil = auto
|
|
21
27
|
# config.overrides = { "Person.name" => "Daniel" }
|
|
@@ -30,16 +36,23 @@ end
|
|
|
30
36
|
# nil — auto: :faker when the gem is loaded, else :literal
|
|
31
37
|
#
|
|
32
38
|
# rspec users: require "graph_weaver/rspec" instead — it hooks the suite
|
|
33
|
-
# (seed from rspec,
|
|
39
|
+
# (seed from rspec, a client per example from its `graphql:` tag).
|
|
34
40
|
module GraphWeaver
|
|
35
41
|
module Testing
|
|
36
42
|
MODES = [:faker, :literal].freeze
|
|
37
43
|
|
|
44
|
+
# What an example can run against, named by the rspec tag that selects
|
|
45
|
+
# it — `it "…", graphql: :in_process` (see graph_weaver/rspec):
|
|
46
|
+
#
|
|
47
|
+
# :fake fabricated, schema-correct data; no resolvers run
|
|
48
|
+
# :in_process your resolvers, one live schema class, in-process
|
|
49
|
+
# :router your resolvers, across a federated graph
|
|
50
|
+
CLIENT_MODES = %i[fake in_process router].freeze
|
|
51
|
+
|
|
38
52
|
class Config
|
|
39
|
-
attr_accessor :overrides, :seed, :list_size, :null_chance, :cassette_dir, :
|
|
53
|
+
attr_accessor :schema, :overrides, :seed, :list_size, :null_chance, :cassette_dir, :context,
|
|
40
54
|
:record, :anonymize
|
|
41
|
-
|
|
42
|
-
attr_reader :mode
|
|
55
|
+
attr_reader :mode, :router, :default_mode
|
|
43
56
|
|
|
44
57
|
def initialize
|
|
45
58
|
@overrides = {}
|
|
@@ -48,12 +61,22 @@ module GraphWeaver
|
|
|
48
61
|
@null_chance = 0.0
|
|
49
62
|
@mode = nil # auto
|
|
50
63
|
@schema = nil
|
|
64
|
+
@located = nil # the committed dump, once located
|
|
65
|
+
# not under spec/fixtures: `fixtures :all` globs that path for
|
|
66
|
+
# `{**,*}/*.yml` and would try to load cassettes as ActiveRecord
|
|
67
|
+
# fixtures, a subdirectory included
|
|
51
68
|
@cassette_dir = "spec/cassettes"
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
@
|
|
56
|
-
#
|
|
69
|
+
# what an example with no `graphql:` tag runs against. nil leaves
|
|
70
|
+
# GraphWeaver.client alone: swapping every example onto something
|
|
71
|
+
# else is too surprising to be a default.
|
|
72
|
+
@default_mode = nil
|
|
73
|
+
# the GraphQL context every :in_process / :router example starts
|
|
74
|
+
# from; graphql_context merges onto it
|
|
75
|
+
@context = {}
|
|
76
|
+
# Router arguments, when the composed supergraph isn't the
|
|
77
|
+
# conventional dump — { supergraph:, subgraphs: }, subgraphs optional
|
|
78
|
+
@router = nil
|
|
79
|
+
# GRAPHWEAVER_RECORD=1 rspec ... -> Testing.cassette re-records
|
|
57
80
|
@record = !ENV["GRAPHWEAVER_RECORD"].to_s.empty?
|
|
58
81
|
# anonymize responses as they're recorded (needs config.schema)
|
|
59
82
|
@anonymize = false
|
|
@@ -61,11 +84,19 @@ module GraphWeaver
|
|
|
61
84
|
|
|
62
85
|
# the explicitly configured schema, else the conventional dump
|
|
63
86
|
# (SchemaLoader.locate at GraphWeaver.schema_path) — nil when
|
|
64
|
-
# neither exists
|
|
87
|
+
# neither exists
|
|
65
88
|
def schema
|
|
66
|
-
|
|
89
|
+
# the dump memoizes separately: explicit_schema has to stay honest
|
|
90
|
+
# about whether anyone set one, since :in_process won't run a dump's
|
|
91
|
+
# resolver-less types as if they were the live class
|
|
92
|
+
@schema || (@located ||= GraphWeaver::SchemaLoader.locate)
|
|
67
93
|
end
|
|
68
94
|
|
|
95
|
+
# What's been set, without falling back to the dump — so validating
|
|
96
|
+
# overrides at configure time doesn't force a schema load on a suite
|
|
97
|
+
# that never asks for one.
|
|
98
|
+
def explicit_schema = @schema
|
|
99
|
+
|
|
69
100
|
def mode=(mode)
|
|
70
101
|
unless mode.nil? || MODES.include?(mode)
|
|
71
102
|
raise ArgumentError, "mode: must be one of #{MODES.inspect} (or nil for auto), got #{mode.inspect}"
|
|
@@ -73,6 +104,104 @@ module GraphWeaver
|
|
|
73
104
|
|
|
74
105
|
@mode = mode
|
|
75
106
|
end
|
|
107
|
+
|
|
108
|
+
def default_mode=(mode)
|
|
109
|
+
unless mode.nil? || CLIENT_MODES.include?(mode)
|
|
110
|
+
raise ArgumentError,
|
|
111
|
+
"default_mode: must be one of #{CLIENT_MODES.inspect} (or nil to leave " \
|
|
112
|
+
"GraphWeaver.client alone), got #{mode.inspect}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
@default_mode = mode
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Router arguments — both keys optional, and each answers a different
|
|
119
|
+
# question. `supergraph:` is for one derivation can't find; without it
|
|
120
|
+
# the conventional dump is used, when that dump is itself a supergraph.
|
|
121
|
+
# `subgraphs:` is for what derivation can't settle, or for `"reviews"
|
|
122
|
+
# => :fake`, which fabricates a subgraph this process doesn't serve —
|
|
123
|
+
# the commonest reason to configure a router at all, and no reason to
|
|
124
|
+
# have to restate where the supergraph is.
|
|
125
|
+
def router=(arguments)
|
|
126
|
+
unless arguments.nil? || arguments.is_a?(Hash)
|
|
127
|
+
raise ArgumentError, "router: must be the arguments to build one, e.g. " \
|
|
128
|
+
"{ supergraph: \"supergraph.graphql\" } or { subgraphs: { \"reviews\" => :fake } }, " \
|
|
129
|
+
"got #{arguments.inspect}"
|
|
130
|
+
end
|
|
131
|
+
if arguments&.key?(:context)
|
|
132
|
+
# the rspec hook resets the router's context from config.context
|
|
133
|
+
# every example, so one set here would silently never be read
|
|
134
|
+
raise ArgumentError, "router: context: is set as config.context — the baseline every " \
|
|
135
|
+
":in_process and :router example starts from"
|
|
136
|
+
end
|
|
137
|
+
unknown = (arguments&.keys || []) - %i[supergraph subgraphs]
|
|
138
|
+
raise ArgumentError, "router: doesn't take #{unknown.join(", ")}" if unknown.any?
|
|
139
|
+
|
|
140
|
+
@router = arguments
|
|
141
|
+
@built_router = nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Built once: parsing the supergraph is setup, not per-example work.
|
|
145
|
+
# #context is settable, so an example that runs as someone else sets
|
|
146
|
+
# that rather than rebuilding — the rspec hook resets it each time.
|
|
147
|
+
def built_router
|
|
148
|
+
@built_router ||= Router.new(supergraph: supergraph!, subgraphs: @router && @router[:subgraphs])
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The composed supergraph :router plans against — named, or the
|
|
152
|
+
# conventional dump when that's what it is. A client can't supply
|
|
153
|
+
# one: its schema is the API schema a router serves, with the
|
|
154
|
+
# @join__* routing table stripped out.
|
|
155
|
+
def supergraph!
|
|
156
|
+
return @router[:supergraph] if @router&.key?(:supergraph)
|
|
157
|
+
|
|
158
|
+
path = GraphWeaver::SchemaLoader.locate_path
|
|
159
|
+
return path if path && supergraph?(path)
|
|
160
|
+
|
|
161
|
+
raise GraphWeaver::Error, ":router needs the composed supergraph SDL — a client's schema " \
|
|
162
|
+
"is the API schema the router serves, with the @join__* routing table stripped out, so " \
|
|
163
|
+
"the supergraph has to be named. #{path ? "#{path} carries no @join__* markers" : "Nothing on disk at #{GraphWeaver.schema_path}"}. " \
|
|
164
|
+
"Set GraphWeaver::Testing.config.router = { supergraph: \"supergraph.graphql\" }."
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The live schema class :in_process runs when the example didn't name
|
|
168
|
+
# one — config.schema if that is a class, else whatever the app's own
|
|
169
|
+
# client already runs in-process. Only a live class has resolvers, so
|
|
170
|
+
# there is nothing else to fall back to: a dump is type information.
|
|
171
|
+
def schema_class!
|
|
172
|
+
runnable(schema) || GraphWeaver.live_schema ||
|
|
173
|
+
raise(GraphWeaver::Error, ":in_process runs your resolvers, so it needs the live " \
|
|
174
|
+
"GraphQL::Schema class — and GraphWeaver.client isn't running one in-process to " \
|
|
175
|
+
"borrow. Name it in the example — graphql_in_process(MySchema) — or set " \
|
|
176
|
+
"GraphWeaver::Testing.config.schema = MySchema for the whole suite. A federated app " \
|
|
177
|
+
"names the subgraph it means, per example; graphql: :router runs the graph stitched.")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The schema everything else derives from: the one you set, else the
|
|
181
|
+
# committed dump, else the schema the app's client talks to.
|
|
182
|
+
def reference_schema!
|
|
183
|
+
found = schema || (GraphWeaver.client.schema if GraphWeaver.client.respond_to?(:schema))
|
|
184
|
+
return found if found
|
|
185
|
+
|
|
186
|
+
raise GraphWeaver::Error, "no schema to run against — GraphWeaver.client isn't set, " \
|
|
187
|
+
"there's no schema dump at #{GraphWeaver.schema_path}, and " \
|
|
188
|
+
"GraphWeaver::Testing.config.schema is unset. Set any one of them."
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
# config.schema doubles as the :in_process class when it is one — but a
|
|
194
|
+
# dump has no resolvers, so it can only ever be type information.
|
|
195
|
+
def runnable(schema)
|
|
196
|
+
schema if schema.is_a?(Class) && schema <= GraphQL::Schema
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def supergraph?(source)
|
|
200
|
+
GraphWeaver::SchemaLoader.routing_table(source)
|
|
201
|
+
true
|
|
202
|
+
rescue GraphWeaver::Error
|
|
203
|
+
false
|
|
204
|
+
end
|
|
76
205
|
end
|
|
77
206
|
|
|
78
207
|
class << self
|
|
@@ -82,6 +211,17 @@ module GraphWeaver
|
|
|
82
211
|
|
|
83
212
|
def configure
|
|
84
213
|
yield config
|
|
214
|
+
# a typo'd override key pins nothing and the test still passes, so
|
|
215
|
+
# catch it here — while the block that set it is still on the stack
|
|
216
|
+
validate_overrides!(config.explicit_schema, config.overrides) if config.explicit_schema
|
|
217
|
+
config
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Override keys name schema coordinates: "Type.field", or a bare field
|
|
221
|
+
# name matching that field on any type. Anything else is a typo that
|
|
222
|
+
# would silently fabricate random data instead of pinning a value.
|
|
223
|
+
def validate_overrides!(schema, overrides)
|
|
224
|
+
overrides.each_key { |key| validate_override_key!(schema, key.to_s) }
|
|
85
225
|
end
|
|
86
226
|
|
|
87
227
|
# back to defaults — between tests, or to undo an experiment
|
|
@@ -94,7 +234,54 @@ module GraphWeaver
|
|
|
94
234
|
def cassette_path(name)
|
|
95
235
|
return name if name.include?("/") || name.end_with?(".yml", ".yaml")
|
|
96
236
|
|
|
97
|
-
File.join(
|
|
237
|
+
File.join(cassette_dir, "#{name}.yml")
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The configured directory, against Rails.root when there is one — a rake
|
|
241
|
+
# task runs from wherever it runs from; the cassettes don't move.
|
|
242
|
+
# const_get rather than a bare Rails: sorbet can't resolve a constant the
|
|
243
|
+
# gem doesn't depend on.
|
|
244
|
+
def cassette_dir
|
|
245
|
+
dir = config.cassette_dir
|
|
246
|
+
root = (Object.const_get(:Rails).root if Object.const_defined?(:Rails))
|
|
247
|
+
root ? File.join(root.to_s, dir) : dir
|
|
248
|
+
rescue NoMethodError
|
|
249
|
+
dir # something else named Rails
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
private
|
|
253
|
+
|
|
254
|
+
def validate_override_key!(schema, key)
|
|
255
|
+
type_name, field_name = key.split(".", 2)
|
|
256
|
+
# introspection fields (__typename) are real but absent from #fields
|
|
257
|
+
return if (field_name || type_name).start_with?("__")
|
|
258
|
+
|
|
259
|
+
if field_name.nil?
|
|
260
|
+
known = field_names(schema)
|
|
261
|
+
return if known.include?(type_name)
|
|
262
|
+
|
|
263
|
+
bad_override!(key, "matches no field in this schema", known, type_name)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
type = schema.get_type(type_name)
|
|
267
|
+
unless type.respond_to?(:fields)
|
|
268
|
+
bad_override!(key, "names no object type in this schema", schema.types.keys, type_name)
|
|
269
|
+
end
|
|
270
|
+
return if type.fields.key?(field_name)
|
|
271
|
+
|
|
272
|
+
bad_override!(key, "is not a field of #{type_name}", type.fields.keys, field_name)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def bad_override!(key, problem, dictionary, term)
|
|
276
|
+
suggestion = GraphWeaver.did_you_mean(dictionary, term)
|
|
277
|
+
hint = suggestion ? " — did you mean '#{suggestion}'?" : ""
|
|
278
|
+
raise GraphWeaver::Error, "override key #{key.inspect} #{problem}#{hint}"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Every output field name in the schema — walked only when a bare key
|
|
282
|
+
# asks for it.
|
|
283
|
+
def field_names(schema)
|
|
284
|
+
schema.types.each_value.flat_map { |type| type.respond_to?(:fields) ? type.fields.keys : [] }.uniq
|
|
98
285
|
end
|
|
99
286
|
end
|
|
100
287
|
end
|
|
@@ -102,5 +289,8 @@ end
|
|
|
102
289
|
|
|
103
290
|
require_relative "testing/values"
|
|
104
291
|
require_relative "testing/fake_client"
|
|
292
|
+
require_relative "testing/fake_subgraph"
|
|
105
293
|
require_relative "testing/failure"
|
|
106
294
|
require_relative "testing/cassette"
|
|
295
|
+
require_relative "testing/router"
|
|
296
|
+
require_relative "testing/coverage"
|
|
@@ -29,34 +29,52 @@ module GraphWeaver
|
|
|
29
29
|
::Faraday::ConnectionFailed, ::Faraday::TimeoutError, ::Faraday::SSLError
|
|
30
30
|
)
|
|
31
31
|
|
|
32
|
-
def initialize(url_or_connection, headers: {}, &block)
|
|
32
|
+
def initialize(url_or_connection, headers: {}, open_timeout: nil, read_timeout: nil, &block)
|
|
33
33
|
@connection = case url_or_connection
|
|
34
34
|
when ::Faraday::Connection
|
|
35
|
-
# a prebuilt connection carries its own headers/middleware
|
|
36
|
-
#
|
|
37
|
-
unless headers.empty? && block.nil?
|
|
35
|
+
# a prebuilt connection carries its own headers/middleware/
|
|
36
|
+
# timeouts, so they'd be silently dropped — fail loudly instead
|
|
37
|
+
unless headers.empty? && block.nil? && open_timeout.nil? && read_timeout.nil?
|
|
38
38
|
raise ArgumentError,
|
|
39
|
-
"headers:/block are ignored when passing a prebuilt Faraday::Connection — configure them on it"
|
|
39
|
+
"headers:/timeouts/block are ignored when passing a prebuilt Faraday::Connection — configure them on it"
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
url_or_connection
|
|
43
43
|
else
|
|
44
|
-
# Faraday appends the default adapter when the block doesn't set
|
|
45
|
-
|
|
44
|
+
# Faraday appends the default adapter when the block doesn't set
|
|
45
|
+
# one. Our defaults go on the connection so ours is the
|
|
46
|
+
# User-Agent, not Faraday's stock one; caller headers still win.
|
|
47
|
+
# Timeouts default to Transport::HTTP's — Faraday would
|
|
48
|
+
# otherwise inherit net/http's 60s/60s.
|
|
49
|
+
::Faraday.new(
|
|
50
|
+
url: url_or_connection,
|
|
51
|
+
headers: DEFAULT_HEADERS.merge(headers),
|
|
52
|
+
request: {
|
|
53
|
+
open_timeout: open_timeout || DEFAULT_OPEN_TIMEOUT,
|
|
54
|
+
read_timeout: read_timeout || DEFAULT_READ_TIMEOUT,
|
|
55
|
+
},
|
|
56
|
+
&block
|
|
57
|
+
)
|
|
46
58
|
end
|
|
47
59
|
@url = @connection.url_prefix.to_s
|
|
60
|
+
|
|
61
|
+
# which adapter got picked decides socket reuse — Faraday's
|
|
62
|
+
# default net_http one opens a connection per request. Naming it
|
|
63
|
+
# is the cheapest way to make that discoverable.
|
|
64
|
+
GraphWeaver.log(:info) { "faraday transport #{@url} (adapter: #{@connection.builder.adapter})" }
|
|
48
65
|
end
|
|
49
66
|
|
|
50
67
|
private
|
|
51
68
|
|
|
52
|
-
sig { override.params(body: String).returns([
|
|
69
|
+
sig { override.params(body: String).returns(T::Array[T.untyped]) }
|
|
53
70
|
def post(body)
|
|
54
71
|
response = @connection.post do |request|
|
|
55
|
-
|
|
72
|
+
# a prebuilt connection owns its headers — only fill the blanks
|
|
73
|
+
DEFAULT_HEADERS.each { |name, value| request.headers[name] ||= value }
|
|
56
74
|
request.body = body
|
|
57
75
|
end
|
|
58
76
|
|
|
59
|
-
[response.status, response.body]
|
|
77
|
+
[response.status, response.body, response.headers.to_h.transform_keys(&:downcase)]
|
|
60
78
|
end
|
|
61
79
|
end
|
|
62
80
|
end
|
|
@@ -13,74 +13,137 @@ module GraphWeaver
|
|
|
13
13
|
#
|
|
14
14
|
# GraphWeaver::Transport::HTTP.new(url, headers: { ... }, read_timeout: 10)
|
|
15
15
|
#
|
|
16
|
-
# Timeouts surface as TransportError (retriable).
|
|
17
|
-
# persistent (keep-alive)
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# Transport::Faraday.
|
|
16
|
+
# Timeouts surface as TransportError (retriable). Connections are
|
|
17
|
+
# persistent (keep-alive) and pooled: up to pool_size: sockets, opened
|
|
18
|
+
# lazily, reused warmest-first, and dropped on any failure so the next
|
|
19
|
+
# call starts fresh. For a middleware ecosystem, use Transport::Faraday.
|
|
21
20
|
class HTTP < Transport
|
|
22
21
|
# net/http's own network-level failures (Errno/SocketError/IOError
|
|
23
22
|
# are already seeded) — added to the shared, extensible
|
|
24
23
|
# transport-error set.
|
|
25
24
|
GraphWeaver.register_transport_error(Timeout::Error, OpenSSL::SSL::SSLError)
|
|
26
25
|
|
|
27
|
-
|
|
26
|
+
# How many requests this process can have in flight at once. Rails sizes
|
|
27
|
+
# its own connection pool from RAILS_MAX_THREADS and this is the same
|
|
28
|
+
# question, so it answers both. A fiber server (Falcon) sets no such
|
|
29
|
+
# ceiling of its own — pass pool_size: there.
|
|
30
|
+
def self.default_pool_size
|
|
31
|
+
threads = ENV["RAILS_MAX_THREADS"].to_i
|
|
32
|
+
threads.positive? ? threads : 5
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(url, headers: {}, open_timeout: DEFAULT_OPEN_TIMEOUT,
|
|
36
|
+
read_timeout: DEFAULT_READ_TIMEOUT, keep_alive_timeout: 2, pool_size: nil,
|
|
37
|
+
ca_file: nil, ca_path: nil, cert: nil, key: nil, verify_mode: nil)
|
|
38
|
+
pool_size ||= self.class.default_pool_size
|
|
39
|
+
raise ArgumentError, "pool_size: must be >= 1" unless pool_size >= 1
|
|
40
|
+
|
|
28
41
|
@url = url
|
|
29
42
|
@uri = URI(url)
|
|
30
43
|
@headers = headers
|
|
31
44
|
@open_timeout = open_timeout
|
|
32
45
|
@read_timeout = read_timeout
|
|
33
46
|
@keep_alive_timeout = keep_alive_timeout
|
|
34
|
-
|
|
35
|
-
|
|
47
|
+
|
|
48
|
+
# TLS, forwarded verbatim to Net::HTTP.start: a private CA
|
|
49
|
+
# (ca_file:/ca_path:), a client certificate (cert:/key:), or a
|
|
50
|
+
# verify_mode: — so mTLS doesn't mean reaching for Faraday
|
|
51
|
+
@ssl = { ca_file:, ca_path:, cert:, key:, verify_mode: }.compact
|
|
52
|
+
if @ssl.any? && @uri.scheme != "https"
|
|
53
|
+
raise ArgumentError, "TLS options need an https url — got #{url}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# One permit per allowed socket: holding a permit is the right to
|
|
57
|
+
# hold a connection, so at most pool_size requests are in flight
|
|
58
|
+
# and the rest queue rather than opening unbounded sockets.
|
|
59
|
+
@pool_size = pool_size
|
|
60
|
+
@permits = SizedQueue.new(pool_size)
|
|
61
|
+
pool_size.times { @permits.push(true) }
|
|
62
|
+
|
|
63
|
+
# live connections, LIFO — a warm socket beats opening a cold one,
|
|
64
|
+
# so a single-threaded caller keeps reusing the same one
|
|
65
|
+
@idle = []
|
|
66
|
+
@idle_lock = Mutex.new
|
|
36
67
|
end
|
|
37
68
|
|
|
38
69
|
private
|
|
39
70
|
|
|
40
|
-
sig { override.params(body: String).returns([
|
|
71
|
+
sig { override.params(body: String).returns(T::Array[T.untyped]) }
|
|
41
72
|
def post(body)
|
|
42
|
-
request = Net::HTTP::Post.new(@uri,
|
|
73
|
+
request = Net::HTTP::Post.new(@uri, DEFAULT_HEADERS.merge(@headers))
|
|
43
74
|
request.body = body
|
|
44
75
|
|
|
45
|
-
response =
|
|
46
|
-
begin
|
|
47
|
-
connection.request(request)
|
|
48
|
-
rescue => e
|
|
49
|
-
# socket state is unknown — drop it so the next call starts
|
|
50
|
-
# fresh (retry policy belongs to Retry, not here)
|
|
51
|
-
disconnect
|
|
52
|
-
raise e
|
|
53
|
-
end
|
|
54
|
-
end
|
|
76
|
+
response = with_connection { |http| http.request(request) }
|
|
55
77
|
|
|
56
|
-
|
|
78
|
+
# each_header yields downcased names with repeats already joined
|
|
79
|
+
[response.code.to_i, response.body, response.each_header.to_h]
|
|
57
80
|
end
|
|
58
81
|
|
|
59
|
-
#
|
|
60
|
-
#
|
|
82
|
+
# Lease a connection for one round trip. The permit is held across
|
|
83
|
+
# the whole trip — opening the socket included — so pool_size really
|
|
84
|
+
# is the concurrency ceiling.
|
|
85
|
+
def with_connection
|
|
86
|
+
acquire_permit
|
|
87
|
+
http = nil
|
|
88
|
+
|
|
89
|
+
begin
|
|
90
|
+
http = @idle_lock.synchronize { @idle.pop } || connect
|
|
91
|
+
result = yield http
|
|
92
|
+
@idle_lock.synchronize { @idle.push(http) }
|
|
93
|
+
result
|
|
94
|
+
rescue Exception
|
|
95
|
+
# socket state is unknown — drop it, leaving the slot empty so
|
|
96
|
+
# the next call starts fresh (retry policy belongs to Retry).
|
|
97
|
+
# Exception, not StandardError: a fiber scheduler cancels with
|
|
98
|
+
# Async::Stop, which descends from Exception.
|
|
99
|
+
disconnect(http)
|
|
100
|
+
raise
|
|
101
|
+
ensure
|
|
102
|
+
@permits.push(true)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A fresh persistent connection. net/http proactively reconnects
|
|
107
|
+
# when idle past keep_alive_timeout, so a server-closed keep-alive
|
|
61
108
|
# socket doesn't produce spurious failures.
|
|
62
|
-
def
|
|
63
|
-
@
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
109
|
+
def connect
|
|
110
|
+
GraphWeaver.log(:debug) { "connecting to #{@uri.hostname}:#{@uri.port}" }
|
|
111
|
+
Net::HTTP.start(
|
|
112
|
+
@uri.hostname, @uri.port,
|
|
113
|
+
use_ssl: @uri.scheme == "https",
|
|
114
|
+
open_timeout: @open_timeout, read_timeout: @read_timeout,
|
|
115
|
+
keep_alive_timeout: @keep_alive_timeout,
|
|
116
|
+
**@ssl,
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Take a permit, saying so when none is free. A queued request is
|
|
121
|
+
# indistinguishable from a slow server from the outside, which is the
|
|
122
|
+
# whole problem: pool_size is a hard ceiling under fibers exactly as
|
|
123
|
+
# under threads. Warned once — a saturated pool stays saturated, and a
|
|
124
|
+
# line per request would bury it.
|
|
125
|
+
def acquire_permit
|
|
126
|
+
@permits.pop(true)
|
|
127
|
+
rescue ThreadError
|
|
128
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
129
|
+
@permits.pop
|
|
130
|
+
waited = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
131
|
+
|
|
132
|
+
first = !@saturated
|
|
133
|
+
@saturated = true
|
|
134
|
+
GraphWeaver.log(first ? :warn : :debug) do
|
|
135
|
+
"connection pool saturated: waited #{waited}ms for 1 of #{@pool_size} connections to " \
|
|
136
|
+
"#{@uri.hostname} — raise pool_size: to this process's concurrency"
|
|
71
137
|
end
|
|
72
138
|
end
|
|
73
139
|
|
|
74
|
-
def disconnect
|
|
75
|
-
http = @http
|
|
140
|
+
def disconnect(http)
|
|
76
141
|
return unless http
|
|
77
142
|
|
|
78
143
|
GraphWeaver.log(:debug) { "dropping connection to #{@uri.hostname}:#{@uri.port}" }
|
|
79
144
|
http.finish if http.started?
|
|
80
145
|
rescue IOError
|
|
81
146
|
# already closed
|
|
82
|
-
ensure
|
|
83
|
-
@http = nil
|
|
84
147
|
end
|
|
85
148
|
end
|
|
86
149
|
end
|