graphql-stitching 0.3.6 → 1.0.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/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
|