graphql-stitching 0.3.6 → 1.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +77 -12
- data/docs/README.md +1 -1
- data/docs/client.md +103 -0
- data/docs/composer.md +2 -2
- data/docs/supergraph.md +1 -1
- data/example/gateway.rb +4 -4
- data/lib/graphql/stitching/boundary.rb +28 -0
- data/lib/graphql/stitching/{gateway.rb → client.rb} +12 -12
- data/lib/graphql/stitching/composer/validate_boundaries.rb +6 -6
- data/lib/graphql/stitching/composer/validate_interfaces.rb +2 -2
- data/lib/graphql/stitching/composer.rb +49 -29
- data/lib/graphql/stitching/executor/boundary_source.rb +199 -0
- data/lib/graphql/stitching/executor/root_source.rb +48 -0
- data/lib/graphql/stitching/executor.rb +6 -229
- data/lib/graphql/stitching/{remote_client.rb → http_executable.rb} +1 -1
- data/lib/graphql/stitching/plan.rb +65 -0
- data/lib/graphql/stitching/planner.rb +70 -89
- data/lib/graphql/stitching/planner_step.rb +63 -0
- data/lib/graphql/stitching/request.rb +2 -51
- data/lib/graphql/stitching/selection_hint.rb +29 -0
- data/lib/graphql/stitching/shaper.rb +2 -2
- data/lib/graphql/stitching/skip_include.rb +81 -0
- data/lib/graphql/stitching/supergraph.rb +29 -23
- data/lib/graphql/stitching/util.rb +49 -38
- data/lib/graphql/stitching/version.rb +1 -1
- data/lib/graphql/stitching.rb +8 -4
- metadata +12 -6
- data/docs/gateway.md +0 -103
- data/lib/graphql/stitching/planner_operation.rb +0 -63
@@ -21,10 +21,14 @@ module GraphQL
|
|
21
21
|
end
|
22
22
|
end
|
23
23
|
|
24
|
+
boundaries = delegation_map["boundaries"].map do |k, b|
|
25
|
+
[k, b.map { Boundary.new(**_1) }]
|
26
|
+
end
|
27
|
+
|
24
28
|
new(
|
25
29
|
schema: schema,
|
26
30
|
fields: delegation_map["fields"],
|
27
|
-
boundaries:
|
31
|
+
boundaries: boundaries.to_h,
|
28
32
|
executables: executables,
|
29
33
|
)
|
30
34
|
end
|
@@ -68,7 +72,7 @@ module GraphQL
|
|
68
72
|
return GraphQL::Schema::Printer.print_schema(@schema), {
|
69
73
|
"locations" => locations,
|
70
74
|
"fields" => fields,
|
71
|
-
"boundaries" => @boundaries,
|
75
|
+
"boundaries" => @boundaries.map { |k, b| [k, b.map(&:as_json)] }.to_h,
|
72
76
|
}
|
73
77
|
end
|
74
78
|
|
@@ -144,7 +148,7 @@ module GraphQL
|
|
144
148
|
# ("Type") => ["id", ...]
|
145
149
|
def possible_keys_for_type(type_name)
|
146
150
|
@possible_keys_by_type[type_name] ||= begin
|
147
|
-
@boundaries[type_name].map
|
151
|
+
@boundaries[type_name].map(&:key).tap(&:uniq!)
|
148
152
|
end
|
149
153
|
end
|
150
154
|
|
@@ -162,74 +166,76 @@ module GraphQL
|
|
162
166
|
# used to connect a partial type across locations via boundary queries
|
163
167
|
def route_type_to_locations(type_name, start_location, goal_locations)
|
164
168
|
if possible_keys_for_type(type_name).length > 1
|
165
|
-
# multiple keys use an
|
169
|
+
# multiple keys use an A* search to traverse intermediary locations
|
166
170
|
return route_type_to_locations_via_search(type_name, start_location, goal_locations)
|
167
171
|
end
|
168
172
|
|
169
173
|
# types with a single key attribute must all be within a single hop of each other,
|
170
174
|
# so can use a simple match to collect boundaries for the goal locations.
|
171
175
|
@boundaries[type_name].each_with_object({}) do |boundary, memo|
|
172
|
-
if goal_locations.include?(boundary
|
173
|
-
memo[boundary
|
176
|
+
if goal_locations.include?(boundary.location)
|
177
|
+
memo[boundary.location] = [boundary]
|
174
178
|
end
|
175
179
|
end
|
176
180
|
end
|
177
181
|
|
178
182
|
private
|
179
183
|
|
180
|
-
|
184
|
+
PathNode = Struct.new(:location, :key, :cost, :boundary, keyword_init: true)
|
185
|
+
|
186
|
+
# tunes A* search to favor paths with fewest joining locations, ie:
|
181
187
|
# favor longer paths through target locations over shorter paths with additional locations.
|
182
188
|
def route_type_to_locations_via_search(type_name, start_location, goal_locations)
|
183
189
|
results = {}
|
184
190
|
costs = {}
|
185
191
|
|
186
192
|
paths = possible_keys_for_type_and_location(type_name, start_location).map do |possible_key|
|
187
|
-
[
|
193
|
+
[PathNode.new(location: start_location, key: possible_key, cost: 0)]
|
188
194
|
end
|
189
195
|
|
190
196
|
while paths.any?
|
191
197
|
path = paths.pop
|
192
|
-
current_location = path.last
|
193
|
-
current_key = path.last
|
194
|
-
current_cost = path.last
|
198
|
+
current_location = path.last.location
|
199
|
+
current_key = path.last.key
|
200
|
+
current_cost = path.last.cost
|
195
201
|
|
196
202
|
@boundaries[type_name].each do |boundary|
|
197
|
-
forward_location = boundary
|
198
|
-
next if current_key != boundary
|
199
|
-
next if path.any? { _1
|
203
|
+
forward_location = boundary.location
|
204
|
+
next if current_key != boundary.key
|
205
|
+
next if path.any? { _1.location == forward_location }
|
200
206
|
|
201
207
|
best_cost = costs[forward_location] || Float::INFINITY
|
202
208
|
next if best_cost < current_cost
|
203
209
|
|
204
210
|
path.pop
|
205
|
-
path <<
|
211
|
+
path << PathNode.new(
|
206
212
|
location: current_location,
|
207
213
|
key: current_key,
|
208
214
|
cost: current_cost,
|
209
215
|
boundary: boundary,
|
210
|
-
|
216
|
+
)
|
211
217
|
|
212
218
|
if goal_locations.include?(forward_location)
|
213
219
|
current_result = results[forward_location]
|
214
220
|
if current_result.nil? || current_cost < best_cost || (current_cost == best_cost && path.length < current_result.length)
|
215
|
-
results[forward_location] = path.map
|
221
|
+
results[forward_location] = path.map(&:boundary)
|
216
222
|
end
|
217
223
|
else
|
218
|
-
path.last
|
224
|
+
path.last.cost += 1
|
219
225
|
end
|
220
226
|
|
221
|
-
forward_cost = path.last
|
227
|
+
forward_cost = path.last.cost
|
222
228
|
costs[forward_location] = forward_cost if forward_cost < best_cost
|
223
229
|
|
224
230
|
possible_keys_for_type_and_location(type_name, forward_location).each do |possible_key|
|
225
|
-
paths << [*path,
|
231
|
+
paths << [*path, PathNode.new(location: forward_location, key: possible_key, cost: forward_cost)]
|
226
232
|
end
|
227
233
|
end
|
228
234
|
|
229
235
|
paths.sort! do |a, b|
|
230
|
-
cost_diff =
|
231
|
-
cost_diff.zero? ?
|
232
|
-
end
|
236
|
+
cost_diff = b.last.cost - a.last.cost
|
237
|
+
cost_diff.zero? ? b.length - a.length : cost_diff
|
238
|
+
end
|
233
239
|
end
|
234
240
|
|
235
241
|
results
|
@@ -3,54 +3,65 @@
|
|
3
3
|
module GraphQL
|
4
4
|
module Stitching
|
5
5
|
class Util
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
end
|
6
|
+
TypeStructure = Struct.new(:list, :null, :name, keyword_init: true) do
|
7
|
+
alias_method :list?, :list
|
8
|
+
alias_method :null?, :null
|
10
9
|
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
type
|
10
|
+
def non_null?
|
11
|
+
!null
|
12
|
+
end
|
15
13
|
end
|
16
14
|
|
17
|
-
|
18
|
-
|
19
|
-
|
15
|
+
class << self
|
16
|
+
# specifies if a type is a primitive leaf value
|
17
|
+
def is_leaf_type?(type)
|
18
|
+
type.kind.scalar? || type.kind.enum?
|
19
|
+
end
|
20
|
+
|
21
|
+
# strips non-null wrappers from a type
|
22
|
+
def unwrap_non_null(type)
|
23
|
+
type = type.of_type while type.non_null?
|
24
|
+
type
|
25
|
+
end
|
26
|
+
|
27
|
+
# builds a single-dimensional representation of a wrapped type structure
|
28
|
+
def flatten_type_structure(type)
|
29
|
+
structure = []
|
30
|
+
|
31
|
+
while type.list?
|
32
|
+
structure << TypeStructure.new(
|
33
|
+
list: true,
|
34
|
+
null: !type.non_null?,
|
35
|
+
name: nil,
|
36
|
+
)
|
37
|
+
|
38
|
+
type = unwrap_non_null(type).of_type
|
39
|
+
end
|
20
40
|
|
21
|
-
|
22
|
-
|
23
|
-
list: true,
|
41
|
+
structure << TypeStructure.new(
|
42
|
+
list: false,
|
24
43
|
null: !type.non_null?,
|
25
|
-
name:
|
26
|
-
|
44
|
+
name: type.unwrap.graphql_name,
|
45
|
+
)
|
27
46
|
|
28
|
-
|
47
|
+
structure
|
29
48
|
end
|
30
49
|
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
structure
|
38
|
-
end
|
50
|
+
# expands interfaces and unions to an array of their memberships
|
51
|
+
# like `schema.possible_types`, but includes child interfaces
|
52
|
+
def expand_abstract_type(schema, parent_type)
|
53
|
+
return [] unless parent_type.kind.abstract?
|
54
|
+
return parent_type.possible_types if parent_type.kind.union?
|
39
55
|
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
next unless type <= GraphQL::Schema::Interface && type != parent_type
|
49
|
-
next unless type.interfaces.include?(parent_type)
|
50
|
-
result << type
|
51
|
-
result.push(*expand_abstract_type(schema, type)) if type.kind.interface?
|
56
|
+
result = []
|
57
|
+
schema.types.values.each do |type|
|
58
|
+
next unless type <= GraphQL::Schema::Interface && type != parent_type
|
59
|
+
next unless type.interfaces.include?(parent_type)
|
60
|
+
result << type
|
61
|
+
result.push(*expand_abstract_type(schema, type)) if type.kind.interface?
|
62
|
+
end
|
63
|
+
result.uniq
|
52
64
|
end
|
53
|
-
result.uniq
|
54
65
|
end
|
55
66
|
end
|
56
67
|
end
|
data/lib/graphql/stitching.rb
CHANGED
@@ -5,11 +5,11 @@ require "graphql"
|
|
5
5
|
module GraphQL
|
6
6
|
module Stitching
|
7
7
|
EMPTY_OBJECT = {}.freeze
|
8
|
+
EMPTY_ARRAY = [].freeze
|
8
9
|
|
9
10
|
class StitchingError < StandardError; end
|
10
11
|
|
11
12
|
class << self
|
12
|
-
|
13
13
|
def stitch_directive
|
14
14
|
@stitch_directive ||= "stitch"
|
15
15
|
end
|
@@ -23,14 +23,18 @@ module GraphQL
|
|
23
23
|
end
|
24
24
|
end
|
25
25
|
|
26
|
-
require_relative "stitching/gateway"
|
27
26
|
require_relative "stitching/supergraph"
|
27
|
+
require_relative "stitching/boundary"
|
28
|
+
require_relative "stitching/client"
|
28
29
|
require_relative "stitching/composer"
|
29
30
|
require_relative "stitching/executor"
|
30
|
-
require_relative "stitching/
|
31
|
+
require_relative "stitching/http_executable"
|
32
|
+
require_relative "stitching/plan"
|
33
|
+
require_relative "stitching/planner_step"
|
31
34
|
require_relative "stitching/planner"
|
32
|
-
require_relative "stitching/remote_client"
|
33
35
|
require_relative "stitching/request"
|
36
|
+
require_relative "stitching/selection_hint"
|
34
37
|
require_relative "stitching/shaper"
|
38
|
+
require_relative "stitching/skip_include"
|
35
39
|
require_relative "stitching/util"
|
36
40
|
require_relative "stitching/version"
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: graphql-stitching
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 1.0.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Greg MacWilliam
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-04
|
11
|
+
date: 2023-10-04 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: graphql
|
@@ -80,9 +80,9 @@ files:
|
|
80
80
|
- README.md
|
81
81
|
- Rakefile
|
82
82
|
- docs/README.md
|
83
|
+
- docs/client.md
|
83
84
|
- docs/composer.md
|
84
85
|
- docs/executor.md
|
85
|
-
- docs/gateway.md
|
86
86
|
- docs/images/library.png
|
87
87
|
- docs/images/merging.png
|
88
88
|
- docs/images/stitching.png
|
@@ -97,17 +97,23 @@ files:
|
|
97
97
|
- gemfiles/graphql_1.13.9.gemfile
|
98
98
|
- graphql-stitching.gemspec
|
99
99
|
- lib/graphql/stitching.rb
|
100
|
+
- lib/graphql/stitching/boundary.rb
|
101
|
+
- lib/graphql/stitching/client.rb
|
100
102
|
- lib/graphql/stitching/composer.rb
|
101
103
|
- lib/graphql/stitching/composer/base_validator.rb
|
102
104
|
- lib/graphql/stitching/composer/validate_boundaries.rb
|
103
105
|
- lib/graphql/stitching/composer/validate_interfaces.rb
|
104
106
|
- lib/graphql/stitching/executor.rb
|
105
|
-
- lib/graphql/stitching/
|
107
|
+
- lib/graphql/stitching/executor/boundary_source.rb
|
108
|
+
- lib/graphql/stitching/executor/root_source.rb
|
109
|
+
- lib/graphql/stitching/http_executable.rb
|
110
|
+
- lib/graphql/stitching/plan.rb
|
106
111
|
- lib/graphql/stitching/planner.rb
|
107
|
-
- lib/graphql/stitching/
|
108
|
-
- lib/graphql/stitching/remote_client.rb
|
112
|
+
- lib/graphql/stitching/planner_step.rb
|
109
113
|
- lib/graphql/stitching/request.rb
|
114
|
+
- lib/graphql/stitching/selection_hint.rb
|
110
115
|
- lib/graphql/stitching/shaper.rb
|
116
|
+
- lib/graphql/stitching/skip_include.rb
|
111
117
|
- lib/graphql/stitching/supergraph.rb
|
112
118
|
- lib/graphql/stitching/util.rb
|
113
119
|
- lib/graphql/stitching/version.rb
|
data/docs/gateway.md
DELETED
@@ -1,103 +0,0 @@
|
|
1
|
-
## GraphQL::Stitching::Gateway
|
2
|
-
|
3
|
-
The `Gateway` is an out-of-the-box convenience with all stitching components assembled into a default workflow. A gateway is designed to work for most common needs, though you're welcome to assemble the component parts into your own configuration. A Gateway is constructed with the same [location settings](./composer.md#performing-composition) used to perform supergraph composition:
|
4
|
-
|
5
|
-
```ruby
|
6
|
-
movies_schema = "type Query { ..."
|
7
|
-
showtimes_schema = "type Query { ..."
|
8
|
-
|
9
|
-
gateway = GraphQL::Stitching::Gateway.new(locations: {
|
10
|
-
products: {
|
11
|
-
schema: GraphQL::Schema.from_definition(movies_schema),
|
12
|
-
executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3000"),
|
13
|
-
stitch: [{ field_name: "products", key: "id" }],
|
14
|
-
},
|
15
|
-
showtimes: {
|
16
|
-
schema: GraphQL::Schema.from_definition(showtimes_schema),
|
17
|
-
executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3001"),
|
18
|
-
},
|
19
|
-
my_local: {
|
20
|
-
schema: MyLocal::GraphQL::Schema,
|
21
|
-
},
|
22
|
-
})
|
23
|
-
```
|
24
|
-
|
25
|
-
Alternatively, you may pass a prebuilt `Supergraph` instance to the Gateway constructor. This is useful when [exporting and rehydrating](./supergraph.md#export-and-caching) supergraph instances, which bypasses the need for runtime composition:
|
26
|
-
|
27
|
-
```ruby
|
28
|
-
exported_schema = "type Query { ..."
|
29
|
-
exported_mapping = JSON.parse("{ ... }")
|
30
|
-
supergraph = GraphQL::Stitching::Supergraph.from_export(
|
31
|
-
schema: exported_schema,
|
32
|
-
delegation_map: exported_mapping,
|
33
|
-
executables: { ... },
|
34
|
-
)
|
35
|
-
|
36
|
-
gateway = GraphQL::Stitching::Gateway.new(supergraph: supergraph)
|
37
|
-
```
|
38
|
-
|
39
|
-
### Execution
|
40
|
-
|
41
|
-
A gateway provides an `execute` method with a subset of arguments provided by [`GraphQL::Schema.execute`](https://graphql-ruby.org/queries/executing_queries). Executing requests on a stitched gateway becomes mostly a drop-in replacement to executing on a `GraphQL::Schema` instance:
|
42
|
-
|
43
|
-
```ruby
|
44
|
-
result = gateway.execute(
|
45
|
-
query: "query MyProduct($id: ID!) { product(id: $id) { name } }",
|
46
|
-
variables: { "id" => "1" },
|
47
|
-
operation_name: "MyProduct",
|
48
|
-
)
|
49
|
-
```
|
50
|
-
|
51
|
-
Arguments for the `execute` method include:
|
52
|
-
|
53
|
-
* `query`: a query (or mutation) as a string or parsed AST.
|
54
|
-
* `variables`: a hash of variables for the request.
|
55
|
-
* `operation_name`: the name of the operation to execute (when multiple are provided).
|
56
|
-
* `validate`: true if static validation should run on the supergraph schema before execution.
|
57
|
-
* `context`: an object passed through to executable calls and gateway hooks.
|
58
|
-
|
59
|
-
### Cache hooks
|
60
|
-
|
61
|
-
The gateway provides cache hooks to enable caching query plans across requests. Without caching, every request made the the gateway will be planned individually. With caching, a query may be planned once, cached, and then executed from cache for subsequent requests. Cache keys are a normalized digest of each query string.
|
62
|
-
|
63
|
-
```ruby
|
64
|
-
gateway.on_cache_read do |key, _context|
|
65
|
-
$redis.get(key) # << 3P code
|
66
|
-
end
|
67
|
-
|
68
|
-
gateway.on_cache_write do |key, payload, _context|
|
69
|
-
$redis.set(key, payload) # << 3P code
|
70
|
-
end
|
71
|
-
```
|
72
|
-
|
73
|
-
Note that inlined input data works against caching, so you should _avoid_ this:
|
74
|
-
|
75
|
-
```graphql
|
76
|
-
query {
|
77
|
-
product(id: "1") { name }
|
78
|
-
}
|
79
|
-
```
|
80
|
-
|
81
|
-
Instead, always leverage variables in queries so that the document body remains consistent across requests:
|
82
|
-
|
83
|
-
```graphql
|
84
|
-
query($id: ID!) {
|
85
|
-
product(id: $id) { name }
|
86
|
-
}
|
87
|
-
|
88
|
-
# variables: { "id" => "1" }
|
89
|
-
```
|
90
|
-
|
91
|
-
### Error hooks
|
92
|
-
|
93
|
-
The gateway also provides an error hook. Any program errors rescued during execution will be passed to the `on_error` handler, which can report on the error as needed and return a formatted error message for the gateway to add to the [GraphQL errors](https://spec.graphql.org/June2018/#sec-Errors) result.
|
94
|
-
|
95
|
-
```ruby
|
96
|
-
gateway.on_error do |err, context|
|
97
|
-
# log the error
|
98
|
-
Bugsnag.notify(err)
|
99
|
-
|
100
|
-
# return a formatted message for the public response
|
101
|
-
"Whoops, please contact support abount request '#{context[:request_id]}'"
|
102
|
-
end
|
103
|
-
```
|
@@ -1,63 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module GraphQL
|
4
|
-
module Stitching
|
5
|
-
class PlannerOperation
|
6
|
-
LANGUAGE_PRINTER = GraphQL::Language::Printer.new
|
7
|
-
|
8
|
-
attr_reader :order, :location, :parent_type, :if_type, :operation_type, :path
|
9
|
-
attr_accessor :after, :selections, :variables, :boundary
|
10
|
-
|
11
|
-
def initialize(
|
12
|
-
location:,
|
13
|
-
parent_type:,
|
14
|
-
order:,
|
15
|
-
after: nil,
|
16
|
-
operation_type: "query",
|
17
|
-
selections: [],
|
18
|
-
variables: [],
|
19
|
-
path: [],
|
20
|
-
if_type: nil,
|
21
|
-
boundary: nil
|
22
|
-
)
|
23
|
-
@location = location
|
24
|
-
@parent_type = parent_type
|
25
|
-
@order = order
|
26
|
-
@after = after
|
27
|
-
@operation_type = operation_type
|
28
|
-
@selections = selections
|
29
|
-
@variables = variables
|
30
|
-
@path = path
|
31
|
-
@if_type = if_type
|
32
|
-
@boundary = boundary
|
33
|
-
end
|
34
|
-
|
35
|
-
def selection_set
|
36
|
-
op = GraphQL::Language::Nodes::OperationDefinition.new(selections: @selections)
|
37
|
-
LANGUAGE_PRINTER.print(op).gsub!(/\s+/, " ").strip!
|
38
|
-
end
|
39
|
-
|
40
|
-
def variable_set
|
41
|
-
@variables.each_with_object({}) do |(variable_name, value_type), memo|
|
42
|
-
memo[variable_name] = LANGUAGE_PRINTER.print(value_type)
|
43
|
-
end
|
44
|
-
end
|
45
|
-
|
46
|
-
def to_h
|
47
|
-
data = {
|
48
|
-
"order" => @order,
|
49
|
-
"after" => @after,
|
50
|
-
"location" => @location,
|
51
|
-
"operation_type" => @operation_type,
|
52
|
-
"selections" => selection_set,
|
53
|
-
"variables" => variable_set,
|
54
|
-
"path" => @path,
|
55
|
-
}
|
56
|
-
|
57
|
-
data["if_type"] = @if_type if @if_type
|
58
|
-
data["boundary"] = @boundary if @boundary
|
59
|
-
data
|
60
|
-
end
|
61
|
-
end
|
62
|
-
end
|
63
|
-
end
|