graphql-stitching 0.3.2 → 0.3.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +16 -4
- data/docs/README.md +4 -0
- data/docs/composer.md +4 -1
- data/docs/mechanics.md +209 -0
- data/lib/graphql/stitching/composer/validate_boundaries.rb +10 -10
- data/lib/graphql/stitching/composer.rb +30 -8
- data/lib/graphql/stitching/executor.rb +2 -4
- data/lib/graphql/stitching/gateway.rb +17 -19
- data/lib/graphql/stitching/planner.rb +61 -39
- data/lib/graphql/stitching/request.rb +1 -1
- data/lib/graphql/stitching/shaper.rb +32 -11
- data/lib/graphql/stitching/supergraph.rb +24 -0
- data/lib/graphql/stitching/util.rb +0 -9
- data/lib/graphql/stitching/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0b8fbbdd8c300982092ee9297953f87e014420801e1a6058a49e36d5de8fb85b
|
4
|
+
data.tar.gz: 1fbd9b21161e8452a2fa55c4617ffabe8834c49e1b0635a58e17ed9b0cb43315
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9eb747176cb0b39ced4ce35a10bab8f4a8e0b7448a5d6a9a74e8e74f484aba7f1bea46c1eff3ca3b118ee3be5fd2f8e7f5ebffb23dd5e6480a92abe620af9523
|
7
|
+
data.tar.gz: 64d935449b052d52b1068f22cc7df4b33608bb2b05bb5f8670da0e045f1c0faa06e44aa137acedab469312db5cda5144755a8d99b116a1c7ac09f809759ef198
|
data/README.md
CHANGED
@@ -5,7 +5,7 @@ GraphQL stitching composes a single schema from multiple underlying GraphQL reso
|
|
5
5
|
![Stitched graph](./docs/images/stitching.png)
|
6
6
|
|
7
7
|
**Supports:**
|
8
|
-
- Merged object and
|
8
|
+
- Merged object and abstract types.
|
9
9
|
- Multiple keys per merged type.
|
10
10
|
- Shared objects, fields, enums, and inputs across locations.
|
11
11
|
- Combining local and remote schemas.
|
@@ -70,9 +70,9 @@ result = gateway.execute(
|
|
70
70
|
)
|
71
71
|
```
|
72
72
|
|
73
|
-
Schemas provided
|
73
|
+
Schemas provided in [location settings](./docs/composer.md#performing-composition) may be class-based schemas with local resolvers (locally-executable schemas), or schemas built from SDL strings (schema definition language parsed using `GraphQL::Schema.from_definition`) and mapped to remote locations. See [composer docs](./docs/composer.md#merge-patterns) for more information on how schemas get merged.
|
74
74
|
|
75
|
-
While the
|
75
|
+
While the `Gateway` constructor is an easy quick start, the library also has several discrete components that can be assembled into custom workflows:
|
76
76
|
|
77
77
|
- [Composer](./docs/composer.md) - merges and validates many schemas into one supergraph.
|
78
78
|
- [Supergraph](./docs/supergraph.md) - manages the combined schema, location routing maps, and executable resources. Can be exported, cached, and rehydrated.
|
@@ -148,7 +148,7 @@ type Query {
|
|
148
148
|
* The `@stitch` directive is applied to a root query where the merged type may be accessed. The merged type identity is inferred from the field return.
|
149
149
|
* The `key: "id"` parameter indicates that an `{ id }` must be selected from prior locations so it may be submitted as an argument to this query. The query argument used to send the key is inferred when possible (more on arguments later).
|
150
150
|
|
151
|
-
Each location that provides a unique variant of a type must provide
|
151
|
+
Each location that provides a unique variant of a type must provide one stitching query per key. The exception to this requirement are types that contain only a single key field:
|
152
152
|
|
153
153
|
```graphql
|
154
154
|
type Product {
|
@@ -166,8 +166,13 @@ It's okay ([even preferable](https://www.youtube.com/watch?v=VmK0KBHTcWs) in man
|
|
166
166
|
type Query {
|
167
167
|
products(ids: [ID!]!): [Product]! @stitch(key: "id")
|
168
168
|
}
|
169
|
+
|
170
|
+
# input: ["1", "2", "3"]
|
171
|
+
# result: [{ id: "1" }, null, { id: "3" }]
|
169
172
|
```
|
170
173
|
|
174
|
+
See [error handling](./docs/mechanics.md#stitched-errors) tips for list queries.
|
175
|
+
|
171
176
|
#### Abstract queries
|
172
177
|
|
173
178
|
It's okay for stitching queries to be implemented through abstract types. An abstract query will provide access to all of its possible types. For interfaces, the key selection should match a field within the interface. For unions, all possible types must implement the key selection individually.
|
@@ -334,6 +339,13 @@ The `GraphQL::Stitching::RemoteClient` class is provided as a simple executable
|
|
334
339
|
|
335
340
|
The [Executor](./docs/executor.md) component builds atop the Ruby fiber-based implementation of `GraphQL::Dataloader`. Non-blocking concurrency requires setting a fiber scheduler via `Fiber.set_scheduler`, see [graphql-ruby docs](https://graphql-ruby.org/dataloader/nonblocking.html). You may also need to build your own remote clients using corresponding HTTP libraries.
|
336
341
|
|
342
|
+
## Additional topics
|
343
|
+
|
344
|
+
- [Field selection routing](./docs/mechanics.md#field-selection-routing)
|
345
|
+
- [Root selection routing](./docs/mechanics.md#root-selection-routing)
|
346
|
+
- [Stitched errors](./docs/mechanics.md#stitched-errors)
|
347
|
+
- [Null results](./docs/mechanics.md#null-results)
|
348
|
+
|
337
349
|
## Example
|
338
350
|
|
339
351
|
This repo includes a working example of several stitched schemas running across small Rack servers. Try running it:
|
data/docs/README.md
CHANGED
@@ -12,3 +12,7 @@ Major components include:
|
|
12
12
|
- [Request](./request.md) - prepares a requested GraphQL document and variables for stitching.
|
13
13
|
- [Planner](./planner.md) - builds a cacheable query plan for a request document.
|
14
14
|
- [Executor](./executor.md) - executes a query plan with given request variables.
|
15
|
+
|
16
|
+
Additional topics:
|
17
|
+
|
18
|
+
- [Stitching mechanics](./mechanics.md) - learn more about building for stitching.
|
data/docs/composer.md
CHANGED
@@ -13,6 +13,7 @@ composer = GraphQL::Stitching::Composer.new(
|
|
13
13
|
description_merger: ->(values_by_location, info) { values_by_location.values.join("\n") },
|
14
14
|
deprecation_merger: ->(values_by_location, info) { values_by_location.values.first },
|
15
15
|
directive_kwarg_merger: ->(values_by_location, info) { values_by_location.values.last },
|
16
|
+
root_field_location_selector: ->(locations, info) { locations.last },
|
16
17
|
)
|
17
18
|
```
|
18
19
|
|
@@ -28,12 +29,14 @@ Constructor arguments:
|
|
28
29
|
|
29
30
|
- **`directive_kwarg_merger:`** _optional_, a [value merger function](#value-merger-functions) for merging directive keyword arguments from across locations.
|
30
31
|
|
32
|
+
- **`root_field_location_selector:`** _optional_, selects a default routing location for root fields with multiple locations. Use this to prioritize sending root fields to their primary data sources (only applies while routing the root operation scope). This handler receives an array of possible locations and an info object with field information, and should return the prioritized location. The last location is used by default.
|
33
|
+
|
31
34
|
#### Value merger functions
|
32
35
|
|
33
36
|
Static data values such as element descriptions and directive arguments must also merge across locations. By default, the first non-null value encountered for a given element attribute is used. A value merger function may customize this process by selecting a different value or computing a new one:
|
34
37
|
|
35
38
|
```ruby
|
36
|
-
|
39
|
+
composer = GraphQL::Stitching::Composer.new(
|
37
40
|
description_merger: ->(values_by_location, info) { values_by_location.values.compact.join("\n") },
|
38
41
|
)
|
39
42
|
```
|
data/docs/mechanics.md
ADDED
@@ -0,0 +1,209 @@
|
|
1
|
+
## Schema Stitching, mechanics
|
2
|
+
|
3
|
+
### Field selection routing
|
4
|
+
|
5
|
+
Fields of a merged type may exist in multiple locations. For example, the `title` field below is provided by both locations:
|
6
|
+
|
7
|
+
```graphql
|
8
|
+
# -- Location A
|
9
|
+
|
10
|
+
type Movie {
|
11
|
+
id: String!
|
12
|
+
title: String! # shared
|
13
|
+
rating: Int!
|
14
|
+
}
|
15
|
+
|
16
|
+
type Query {
|
17
|
+
movieA(id: ID!): Movie @stitch(key: "id")
|
18
|
+
}
|
19
|
+
|
20
|
+
# -- Location B
|
21
|
+
|
22
|
+
type Movie {
|
23
|
+
id: String!
|
24
|
+
title: String! # shared
|
25
|
+
reviews: [String!]!
|
26
|
+
}
|
27
|
+
|
28
|
+
type Query {
|
29
|
+
movieB(id: ID!): Movie @stitch(key: "id")
|
30
|
+
}
|
31
|
+
```
|
32
|
+
|
33
|
+
When planning a request, field selections always attempt to use the current routing location that originates from the selection root, for example:
|
34
|
+
|
35
|
+
```graphql
|
36
|
+
query GetTitleFromA {
|
37
|
+
movieA(id: "23") { # <- enter via Location A
|
38
|
+
title # <- source from Location A
|
39
|
+
}
|
40
|
+
}
|
41
|
+
|
42
|
+
query GetTitleFromB {
|
43
|
+
movieB(id: "23") { # <- enter via Location B
|
44
|
+
title # <- source from Location B
|
45
|
+
}
|
46
|
+
}
|
47
|
+
```
|
48
|
+
|
49
|
+
Field selections that are NOT available in the current routing location delegate to new locations as follows:
|
50
|
+
|
51
|
+
1. Fields with only one location automatically use that location.
|
52
|
+
2. Fields with multiple locations attempt to use a location added during step-1.
|
53
|
+
3. Any remaining fields pick a location based on their highest availability among locations.
|
54
|
+
|
55
|
+
### Root selection routing
|
56
|
+
|
57
|
+
Root fields should route to the primary locations of their provided types. This assures that the most common data for a type can be resolved via root access and thus avoid unnecessary stitching. Root fields can select their primary locations using the `root_field_location_selector` option in [composer configuration](./composer.md#configuring-composition):
|
58
|
+
|
59
|
+
```ruby
|
60
|
+
supergraph = GraphQL::Stitching::Composer.new(
|
61
|
+
root_field_location_selector: ->(locations) { locations.find { _1 == "a" } || locations.last },
|
62
|
+
).perform({ ... })
|
63
|
+
```
|
64
|
+
|
65
|
+
It's okay if root field names are repeated across locations. The primary location will be used when routing root selections:
|
66
|
+
|
67
|
+
```graphql
|
68
|
+
# -- Location A
|
69
|
+
|
70
|
+
type Movie {
|
71
|
+
id: String!
|
72
|
+
rating: Int!
|
73
|
+
}
|
74
|
+
|
75
|
+
type Query {
|
76
|
+
movie(id: ID!): Movie @stitch(key: "id") # shared, primary
|
77
|
+
}
|
78
|
+
|
79
|
+
# -- Location B
|
80
|
+
|
81
|
+
type Movie {
|
82
|
+
id: String!
|
83
|
+
reviews: [String!]!
|
84
|
+
}
|
85
|
+
|
86
|
+
type Query {
|
87
|
+
movie(id: ID!): Movie @stitch(key: "id") # shared
|
88
|
+
}
|
89
|
+
|
90
|
+
# -- Request
|
91
|
+
|
92
|
+
query {
|
93
|
+
movie(id: "23") { id } # routes to Location A
|
94
|
+
}
|
95
|
+
```
|
96
|
+
|
97
|
+
Note that primary location routing _only_ applies to selections in the root scope. If the `Query` type appears again lower in the graph, then its fields are resolved as normal object fields outside of root context, for example:
|
98
|
+
|
99
|
+
```graphql
|
100
|
+
schema {
|
101
|
+
query: Query # << root query, uses primary locations
|
102
|
+
}
|
103
|
+
|
104
|
+
type Query {
|
105
|
+
subquery: Query # << subquery, acts as a normal object type
|
106
|
+
}
|
107
|
+
```
|
108
|
+
|
109
|
+
Also note that stitching queries (denoted by the `@stitch` directive) are completely separate from field routing concerns. A `@stitch` directive establishes a contract for resolving a given type in a given location. This contract is always used to collect stitching data, regardless of how request routing selected the location for use.
|
110
|
+
|
111
|
+
### Stitched errors
|
112
|
+
|
113
|
+
Any [spec GraphQL errors](https://spec.graphql.org/June2018/#sec-Errors) returned by a stitching query will flow through the request. Stitching has two strategies for passing errors through to the final result:
|
114
|
+
|
115
|
+
1. **Direct passthrough**, where subgraph errors are returned directly without modification. This strategy is used for errors without a `path` (ie: "base" errors), and errors pathed to root fields.
|
116
|
+
|
117
|
+
2. **Mapped passthrough**, where the `path` attribute of a subgraph error is remapped to an insertion point in the stitched request. This strategy is used when merging stitching queries into the composed result.
|
118
|
+
|
119
|
+
In either strategy, it's important that subgraphs provide properly pathed errors (GraphQL Ruby [can do this automatically](https://graphql-ruby.org/errors/overview.html)). For example:
|
120
|
+
|
121
|
+
```json
|
122
|
+
{
|
123
|
+
"data": { "shop": { "product": null } },
|
124
|
+
"errors": [{
|
125
|
+
"message": "Record not found.",
|
126
|
+
"path": ["shop", "product"]
|
127
|
+
}]
|
128
|
+
}
|
129
|
+
```
|
130
|
+
|
131
|
+
When resolving [stitching list queries](../README.md#list-queries), it's important to only error out specific array positions rather than the entire array result, for example:
|
132
|
+
|
133
|
+
```ruby
|
134
|
+
def products
|
135
|
+
[
|
136
|
+
{ id: "1" },
|
137
|
+
GraphQL::ExecutionError.new("Not found"),
|
138
|
+
{ id: "3" },
|
139
|
+
]
|
140
|
+
end
|
141
|
+
```
|
142
|
+
|
143
|
+
Stitching expects list queries to pad their missing elements with null, and to report corresponding errors pathed down to list position:
|
144
|
+
|
145
|
+
```json
|
146
|
+
{
|
147
|
+
"data": {
|
148
|
+
"products": [{ "id": "1" }, null, { "id": "3" }]
|
149
|
+
},
|
150
|
+
"errors": [{
|
151
|
+
"message": "Record not found.",
|
152
|
+
"path": ["products", 1]
|
153
|
+
}]
|
154
|
+
}
|
155
|
+
```
|
156
|
+
|
157
|
+
### Null results
|
158
|
+
|
159
|
+
It's okay for a stitching query to return `null` for a merged type as long as all unique fields of the type allow null. For example, the following merge works:
|
160
|
+
|
161
|
+
```graphql
|
162
|
+
# -- Request
|
163
|
+
|
164
|
+
query {
|
165
|
+
movieA(id: "23") {
|
166
|
+
id
|
167
|
+
title
|
168
|
+
rating
|
169
|
+
}
|
170
|
+
}
|
171
|
+
|
172
|
+
# -- Location A
|
173
|
+
|
174
|
+
type Movie {
|
175
|
+
id: String!
|
176
|
+
title: String!
|
177
|
+
}
|
178
|
+
|
179
|
+
type Query {
|
180
|
+
movieA(id: ID!): Movie @stitch(key: "id")
|
181
|
+
# (id: "23") -> { id: "23", title: "Jurassic Park" }
|
182
|
+
}
|
183
|
+
|
184
|
+
# -- Location B
|
185
|
+
|
186
|
+
type Movie {
|
187
|
+
id: String!
|
188
|
+
rating: Int
|
189
|
+
}
|
190
|
+
|
191
|
+
type Query {
|
192
|
+
movieB(id: ID!): Movie @stitch(key: "id")
|
193
|
+
# (id: "23") -> null
|
194
|
+
}
|
195
|
+
```
|
196
|
+
|
197
|
+
And produces this result:
|
198
|
+
|
199
|
+
```json
|
200
|
+
{
|
201
|
+
"data": {
|
202
|
+
"id": "23",
|
203
|
+
"title": "Jurassic Park",
|
204
|
+
"rating": null
|
205
|
+
}
|
206
|
+
}
|
207
|
+
```
|
208
|
+
|
209
|
+
Location B is allowed to return `null` here because its one unique field, `rating`, is nullable (the `id` field can be provided by Location A). If `rating` were non-null, then null bubbling would invalidate the response data.
|
@@ -12,21 +12,21 @@ module GraphQL
|
|
12
12
|
next if type.graphql_name.start_with?("__")
|
13
13
|
|
14
14
|
# multiple subschemas implement the type
|
15
|
-
|
16
|
-
next unless
|
15
|
+
candidate_types_by_location = composer.candidate_types_by_name_and_location[type_name]
|
16
|
+
next unless candidate_types_by_location.length > 1
|
17
17
|
|
18
18
|
boundaries = ctx.boundaries[type_name]
|
19
19
|
if boundaries&.any?
|
20
|
-
validate_as_boundary(ctx, type,
|
20
|
+
validate_as_boundary(ctx, type, candidate_types_by_location, boundaries)
|
21
21
|
elsif type.kind.object?
|
22
|
-
validate_as_shared(ctx, type,
|
22
|
+
validate_as_shared(ctx, type, candidate_types_by_location)
|
23
23
|
end
|
24
24
|
end
|
25
25
|
end
|
26
26
|
|
27
27
|
private
|
28
28
|
|
29
|
-
def validate_as_boundary(ctx, type,
|
29
|
+
def validate_as_boundary(ctx, type, candidate_types_by_location, boundaries)
|
30
30
|
# abstract boundaries are expanded with their concrete implementations, which each get validated. Ignore the abstract itself.
|
31
31
|
return if type.kind.abstract?
|
32
32
|
|
@@ -42,19 +42,19 @@ module GraphQL
|
|
42
42
|
end
|
43
43
|
|
44
44
|
boundary_keys = boundaries.map { _1["selection"] }.uniq
|
45
|
-
key_only_types_by_location =
|
45
|
+
key_only_types_by_location = candidate_types_by_location.select do |location, subschema_type|
|
46
46
|
subschema_type.fields.keys.length == 1 && boundary_keys.include?(subschema_type.fields.keys.first)
|
47
47
|
end
|
48
48
|
|
49
49
|
# all locations have a boundary, or else are key-only
|
50
|
-
|
50
|
+
candidate_types_by_location.each do |location, subschema_type|
|
51
51
|
unless boundaries_by_location_and_key[location] || key_only_types_by_location[location]
|
52
52
|
raise Composer::ValidationError, "A boundary query is required for `#{type.graphql_name}` in #{location} because it provides unique fields."
|
53
53
|
end
|
54
54
|
end
|
55
55
|
|
56
56
|
outbound_access_locations = key_only_types_by_location.keys
|
57
|
-
bidirectional_access_locations =
|
57
|
+
bidirectional_access_locations = candidate_types_by_location.keys - outbound_access_locations
|
58
58
|
|
59
59
|
# verify that all outbound locations can access all inbound locations
|
60
60
|
(outbound_access_locations + bidirectional_access_locations).each do |location|
|
@@ -67,7 +67,7 @@ module GraphQL
|
|
67
67
|
end
|
68
68
|
end
|
69
69
|
|
70
|
-
def validate_as_shared(ctx, type,
|
70
|
+
def validate_as_shared(ctx, type, candidate_types_by_location)
|
71
71
|
expected_fields = begin
|
72
72
|
type.fields.keys.sort
|
73
73
|
rescue StandardError => e
|
@@ -79,7 +79,7 @@ module GraphQL
|
|
79
79
|
end
|
80
80
|
end
|
81
81
|
|
82
|
-
|
82
|
+
candidate_types_by_location.each do |location, subschema_type|
|
83
83
|
if subschema_type.fields.keys.sort != expected_fields
|
84
84
|
raise Composer::ValidationError, "Shared type `#{type.graphql_name}` must have consistent fields across locations, "\
|
85
85
|
"or else define boundary queries so that its unique fields may be accessed remotely."
|
@@ -6,9 +6,10 @@ module GraphQL
|
|
6
6
|
class ComposerError < StitchingError; end
|
7
7
|
class ValidationError < ComposerError; end
|
8
8
|
|
9
|
-
attr_reader :query_name, :mutation_name, :
|
9
|
+
attr_reader :query_name, :mutation_name, :candidate_types_by_name_and_location, :schema_directives
|
10
10
|
|
11
11
|
DEFAULT_VALUE_MERGER = ->(values_by_location, _info) { values_by_location.values.find { !_1.nil? } }
|
12
|
+
DEFAULT_ROOT_FIELD_LOCATION_SELECTOR = ->(locations, _info) { locations.last }
|
12
13
|
|
13
14
|
VALIDATORS = [
|
14
15
|
"ValidateInterfaces",
|
@@ -20,13 +21,15 @@ module GraphQL
|
|
20
21
|
mutation_name: "Mutation",
|
21
22
|
description_merger: nil,
|
22
23
|
deprecation_merger: nil,
|
23
|
-
directive_kwarg_merger: nil
|
24
|
+
directive_kwarg_merger: nil,
|
25
|
+
root_field_location_selector: nil
|
24
26
|
)
|
25
27
|
@query_name = query_name
|
26
28
|
@mutation_name = mutation_name
|
27
29
|
@description_merger = description_merger || DEFAULT_VALUE_MERGER
|
28
30
|
@deprecation_merger = deprecation_merger || DEFAULT_VALUE_MERGER
|
29
31
|
@directive_kwarg_merger = directive_kwarg_merger || DEFAULT_VALUE_MERGER
|
32
|
+
@root_field_location_selector = root_field_location_selector || DEFAULT_ROOT_FIELD_LOCATION_SELECTOR
|
30
33
|
end
|
31
34
|
|
32
35
|
def perform(locations_input)
|
@@ -34,7 +37,7 @@ module GraphQL
|
|
34
37
|
schemas, executables = prepare_locations_input(locations_input)
|
35
38
|
|
36
39
|
# "directive_name" => "location" => candidate_directive
|
37
|
-
@
|
40
|
+
@candidate_directives_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo|
|
38
41
|
(schema.directives.keys - schema.default_directives.keys - GraphQL::Stitching.stitching_directive_names).each do |directive_name|
|
39
42
|
memo[directive_name] ||= {}
|
40
43
|
memo[directive_name][location] = schema.directives[directive_name]
|
@@ -42,14 +45,14 @@ module GraphQL
|
|
42
45
|
end
|
43
46
|
|
44
47
|
# "Typename" => merged_directive
|
45
|
-
@schema_directives = @
|
48
|
+
@schema_directives = @candidate_directives_by_name_and_location.each_with_object({}) do |(directive_name, directives_by_location), memo|
|
46
49
|
memo[directive_name] = build_directive(directive_name, directives_by_location)
|
47
50
|
end
|
48
51
|
|
49
52
|
@schema_directives.merge!(GraphQL::Schema.default_directives)
|
50
53
|
|
51
54
|
# "Typename" => "location" => candidate_type
|
52
|
-
@
|
55
|
+
@candidate_types_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo|
|
53
56
|
raise ComposerError, "Location keys must be strings" unless location.is_a?(String)
|
54
57
|
raise ComposerError, "The subscription operation is not supported." if schema.subscription
|
55
58
|
|
@@ -74,7 +77,7 @@ module GraphQL
|
|
74
77
|
enum_usage = build_enum_usage_map(schemas.values)
|
75
78
|
|
76
79
|
# "Typename" => merged_type
|
77
|
-
schema_types = @
|
80
|
+
schema_types = @candidate_types_by_name_and_location.each_with_object({}) do |(type_name, types_by_location), memo|
|
78
81
|
kinds = types_by_location.values.map { _1.kind.name }.uniq
|
79
82
|
|
80
83
|
if kinds.length > 1
|
@@ -110,6 +113,7 @@ module GraphQL
|
|
110
113
|
own_orphan_types.clear
|
111
114
|
end
|
112
115
|
|
116
|
+
select_root_field_locations(schema)
|
113
117
|
expand_abstract_boundaries(schema)
|
114
118
|
|
115
119
|
supergraph = Supergraph.new(
|
@@ -498,13 +502,31 @@ module GraphQL
|
|
498
502
|
end
|
499
503
|
end
|
500
504
|
|
505
|
+
def select_root_field_locations(schema)
|
506
|
+
[schema.query, schema.mutation].tap(&:compact!).each do |root_type|
|
507
|
+
root_type.fields.each do |root_field_name, root_field|
|
508
|
+
root_field_locations = @field_map[root_type.graphql_name][root_field_name]
|
509
|
+
next unless root_field_locations.length > 1
|
510
|
+
|
511
|
+
target_location = @root_field_location_selector.call(root_field_locations, {
|
512
|
+
type_name: root_type.graphql_name,
|
513
|
+
field_name: root_field_name,
|
514
|
+
})
|
515
|
+
next unless root_field_locations.include?(target_location)
|
516
|
+
|
517
|
+
root_field_locations.reject! { _1 == target_location }
|
518
|
+
root_field_locations.unshift(target_location)
|
519
|
+
end
|
520
|
+
end
|
521
|
+
end
|
522
|
+
|
501
523
|
def expand_abstract_boundaries(schema)
|
502
524
|
@boundary_map.keys.each do |type_name|
|
503
525
|
boundary_type = schema.types[type_name]
|
504
526
|
next unless boundary_type.kind.abstract?
|
505
527
|
|
506
528
|
expanded_types = Util.expand_abstract_type(schema, boundary_type)
|
507
|
-
expanded_types.select { @
|
529
|
+
expanded_types.select { @candidate_types_by_name_and_location[_1.graphql_name].length > 1 }.each do |expanded_type|
|
508
530
|
@boundary_map[expanded_type.graphql_name] ||= []
|
509
531
|
@boundary_map[expanded_type.graphql_name].push(*@boundary_map[type_name])
|
510
532
|
end
|
@@ -555,7 +577,7 @@ module GraphQL
|
|
555
577
|
@field_map = {}
|
556
578
|
@boundary_map = {}
|
557
579
|
@mapped_type_names = {}
|
558
|
-
@
|
580
|
+
@candidate_directives_by_name_and_location = nil
|
559
581
|
@schema_directives = nil
|
560
582
|
end
|
561
583
|
end
|
@@ -58,9 +58,7 @@ module GraphQL
|
|
58
58
|
def fetch(ops)
|
59
59
|
origin_sets_by_operation = ops.each_with_object({}) do |op, memo|
|
60
60
|
origin_set = op["insertion_path"].reduce([@executor.data]) do |set, path_segment|
|
61
|
-
|
62
|
-
mapped.compact!
|
63
|
-
mapped
|
61
|
+
set.flat_map { |obj| obj && obj[path_segment] }.tap(&:compact!)
|
64
62
|
end
|
65
63
|
|
66
64
|
if op["type_condition"]
|
@@ -253,7 +251,7 @@ module GraphQL
|
|
253
251
|
|
254
252
|
if @data && @data.length > 0
|
255
253
|
result["data"] = raw ? @data : GraphQL::Stitching::Shaper.new(
|
256
|
-
|
254
|
+
supergraph: @supergraph,
|
257
255
|
request: @request,
|
258
256
|
).perform!(@data)
|
259
257
|
end
|
@@ -35,25 +35,25 @@ module GraphQL
|
|
35
35
|
return error_result(validation_errors) if validation_errors.any?
|
36
36
|
end
|
37
37
|
|
38
|
-
|
39
|
-
request.prepare!
|
38
|
+
request.prepare!
|
40
39
|
|
41
|
-
|
42
|
-
|
43
|
-
supergraph: @supergraph,
|
44
|
-
request: request,
|
45
|
-
).perform.to_h
|
46
|
-
end
|
47
|
-
|
48
|
-
GraphQL::Stitching::Executor.new(
|
40
|
+
plan = fetch_plan(request) do
|
41
|
+
GraphQL::Stitching::Planner.new(
|
49
42
|
supergraph: @supergraph,
|
50
43
|
request: request,
|
51
|
-
|
52
|
-
).perform
|
53
|
-
rescue StandardError => e
|
54
|
-
custom_message = @on_error.call(e, request.context) if @on_error
|
55
|
-
error_result([{ "message" => custom_message || "An unexpected error occured." }])
|
44
|
+
).perform.to_h
|
56
45
|
end
|
46
|
+
|
47
|
+
GraphQL::Stitching::Executor.new(
|
48
|
+
supergraph: @supergraph,
|
49
|
+
request: request,
|
50
|
+
plan: plan,
|
51
|
+
).perform
|
52
|
+
rescue GraphQL::ParseError, GraphQL::ExecutionError => e
|
53
|
+
error_result([e])
|
54
|
+
rescue StandardError => e
|
55
|
+
custom_message = @on_error.call(e, request.context) if @on_error
|
56
|
+
error_result([{ "message" => custom_message || "An unexpected error occured." }])
|
57
57
|
end
|
58
58
|
|
59
59
|
def on_cache_read(&block)
|
@@ -89,10 +89,8 @@ module GraphQL
|
|
89
89
|
end
|
90
90
|
|
91
91
|
def error_result(errors)
|
92
|
-
public_errors = errors.map do |e|
|
93
|
-
|
94
|
-
public_error["path"] ||= []
|
95
|
-
public_error
|
92
|
+
public_errors = errors.map! do |e|
|
93
|
+
e.is_a?(Hash) ? e : e.to_h
|
96
94
|
end
|
97
95
|
|
98
96
|
{ "errors" => public_errors }
|
@@ -37,12 +37,11 @@ module GraphQL
|
|
37
37
|
when "query"
|
38
38
|
parent_type = @supergraph.schema.query
|
39
39
|
|
40
|
-
selections_by_location =
|
40
|
+
selections_by_location = {}
|
41
|
+
each_selection_in_type(parent_type, @request.operation.selections) do |node|
|
41
42
|
locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name] || SUPERGRAPH_LOCATIONS
|
42
|
-
|
43
|
-
|
44
|
-
memo[locations.last] ||= []
|
45
|
-
memo[locations.last] << node
|
43
|
+
selections_by_location[locations.first] ||= []
|
44
|
+
selections_by_location[locations.first] << node
|
46
45
|
end
|
47
46
|
|
48
47
|
selections_by_location.each do |location, selections|
|
@@ -52,15 +51,15 @@ module GraphQL
|
|
52
51
|
when "mutation"
|
53
52
|
parent_type = @supergraph.schema.mutation
|
54
53
|
|
55
|
-
location_groups =
|
56
|
-
|
57
|
-
next_location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].
|
54
|
+
location_groups = []
|
55
|
+
each_selection_in_type(parent_type, @request.operation.selections) do |node|
|
56
|
+
next_location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].first
|
58
57
|
|
59
|
-
if
|
60
|
-
|
58
|
+
if location_groups.none? || location_groups.last[:location] != next_location
|
59
|
+
location_groups << { location: next_location, selections: [] }
|
61
60
|
end
|
62
61
|
|
63
|
-
|
62
|
+
location_groups.last[:selections] << node
|
64
63
|
end
|
65
64
|
|
66
65
|
location_groups.reduce(0) do |after_key, group|
|
@@ -78,6 +77,27 @@ module GraphQL
|
|
78
77
|
end
|
79
78
|
end
|
80
79
|
|
80
|
+
def each_selection_in_type(parent_type, input_selections, &block)
|
81
|
+
input_selections.each do |node|
|
82
|
+
case node
|
83
|
+
when GraphQL::Language::Nodes::Field
|
84
|
+
yield(node)
|
85
|
+
|
86
|
+
when GraphQL::Language::Nodes::InlineFragment
|
87
|
+
next unless parent_type.graphql_name == node.type.name
|
88
|
+
each_selection_in_type(parent_type, node.selections, &block)
|
89
|
+
|
90
|
+
when GraphQL::Language::Nodes::FragmentSpread
|
91
|
+
fragment = @request.fragment_definitions[node.name]
|
92
|
+
next unless parent_type.graphql_name == fragment.type.name
|
93
|
+
each_selection_in_type(parent_type, fragment.selections, &block)
|
94
|
+
|
95
|
+
else
|
96
|
+
raise "Unexpected node of type #{node.class.name} in selection set."
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
81
101
|
# adds an operation (data access) to the plan which maps a data selection to an insertion point.
|
82
102
|
# note that planned operations are NOT always 1:1 with executed requests, as the executor can
|
83
103
|
# frequently batch different insertion points with the same location into a single request.
|
@@ -101,11 +121,8 @@ module GraphQL
|
|
101
121
|
# groupings coalesce similar operation parameters into a single operation
|
102
122
|
# multiple operations per service may still occur with different insertion points,
|
103
123
|
# but those will get query-batched together during execution.
|
104
|
-
grouping = String.new
|
105
|
-
|
106
|
-
grouping = insertion_path.reduce(grouping) do |memo, segment|
|
107
|
-
memo << "/" << segment
|
108
|
-
end
|
124
|
+
grouping = String.new("#{after_key}/#{location}/#{parent_type.graphql_name}")
|
125
|
+
insertion_path.each { grouping << "/#{_1}" }
|
109
126
|
|
110
127
|
if op = @operations_by_grouping[grouping]
|
111
128
|
op.selections.concat(locale_selections)
|
@@ -158,7 +175,7 @@ module GraphQL
|
|
158
175
|
next
|
159
176
|
end
|
160
177
|
|
161
|
-
field_type =
|
178
|
+
field_type = @supergraph.memoized_schema_fields(parent_type.graphql_name)[node.name].type.unwrap
|
162
179
|
extract_node_variables(node, locale_variables)
|
163
180
|
|
164
181
|
if Util.is_leaf_type?(field_type)
|
@@ -174,7 +191,7 @@ module GraphQL
|
|
174
191
|
when GraphQL::Language::Nodes::InlineFragment
|
175
192
|
next unless @supergraph.locations_by_type[node.type.name].include?(current_location)
|
176
193
|
|
177
|
-
fragment_type = @supergraph.
|
194
|
+
fragment_type = @supergraph.memoized_schema_types[node.type.name]
|
178
195
|
selection_set = extract_locale_selections(current_location, fragment_type, node.selections, insertion_path, after_key, locale_variables)
|
179
196
|
locale_selections << node.merge(selections: selection_set)
|
180
197
|
implements_fragments = true
|
@@ -183,7 +200,7 @@ module GraphQL
|
|
183
200
|
fragment = @request.fragment_definitions[node.name]
|
184
201
|
next unless @supergraph.locations_by_type[fragment.type.name].include?(current_location)
|
185
202
|
|
186
|
-
fragment_type = @supergraph.
|
203
|
+
fragment_type = @supergraph.memoized_schema_types[fragment.type.name]
|
187
204
|
selection_set = extract_locale_selections(current_location, fragment_type, fragment.selections, insertion_path, after_key, locale_variables)
|
188
205
|
locale_selections << GraphQL::Language::Nodes::InlineFragment.new(type: fragment.type, selections: selection_set)
|
189
206
|
implements_fragments = true
|
@@ -219,7 +236,7 @@ module GraphQL
|
|
219
236
|
possible_locations_by_field = @supergraph.locations_by_type_and_field[parent_type.graphql_name]
|
220
237
|
selections_by_location = {}
|
221
238
|
|
222
|
-
# distribute unique fields among required locations
|
239
|
+
# 1. distribute unique fields among required locations
|
223
240
|
remote_selections.reject! do |node|
|
224
241
|
possible_locations = possible_locations_by_field[node.name]
|
225
242
|
if possible_locations.length == 1
|
@@ -229,13 +246,22 @@ module GraphQL
|
|
229
246
|
end
|
230
247
|
end
|
231
248
|
|
232
|
-
# distribute non-unique fields among
|
249
|
+
# 2. distribute non-unique fields among locations that are already used
|
250
|
+
if selections_by_location.any? && remote_selections.any?
|
251
|
+
remote_selections.reject! do |node|
|
252
|
+
used_location = possible_locations_by_field[node.name].find { selections_by_location[_1] }
|
253
|
+
if used_location
|
254
|
+
selections_by_location[used_location] << node
|
255
|
+
true
|
256
|
+
end
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
# 3. distribute remaining fields among locations weighted by greatest availability
|
233
261
|
if remote_selections.any?
|
234
|
-
|
235
|
-
location_weights = if remote_selections.length > 1
|
262
|
+
field_count_by_location = if remote_selections.length > 1
|
236
263
|
remote_selections.each_with_object({}) do |node, memo|
|
237
|
-
|
238
|
-
possible_locations.each do |location|
|
264
|
+
possible_locations_by_field[node.name].each do |location|
|
239
265
|
memo[location] ||= 0
|
240
266
|
memo[location] += 1
|
241
267
|
end
|
@@ -246,18 +272,16 @@ module GraphQL
|
|
246
272
|
|
247
273
|
remote_selections.each do |node|
|
248
274
|
possible_locations = possible_locations_by_field[node.name]
|
249
|
-
|
275
|
+
preferred_location = possible_locations.first
|
250
276
|
|
251
|
-
|
252
|
-
|
253
|
-
score = selections_by_location[possible_location] ? remote_selections.length : 0
|
254
|
-
score += location_weights.fetch(possible_location, 0)
|
277
|
+
possible_locations.reduce(0) do |max_availability, possible_location|
|
278
|
+
available_fields = field_count_by_location.fetch(possible_location, 0)
|
255
279
|
|
256
|
-
if
|
257
|
-
|
258
|
-
|
280
|
+
if available_fields > max_availability
|
281
|
+
preferred_location = possible_location
|
282
|
+
available_fields
|
259
283
|
else
|
260
|
-
|
284
|
+
max_availability
|
261
285
|
end
|
262
286
|
end
|
263
287
|
|
@@ -272,10 +296,8 @@ module GraphQL
|
|
272
296
|
routes.values.each_with_object({}) do |route, ops_by_location|
|
273
297
|
route.reduce(nil) do |parent_op, boundary|
|
274
298
|
location = boundary["location"]
|
275
|
-
new_operation = false
|
276
299
|
|
277
300
|
unless op = ops_by_location[location]
|
278
|
-
new_operation = true
|
279
301
|
op = ops_by_location[location] = add_operation(
|
280
302
|
location: location,
|
281
303
|
# routing locations added as intermediaries have no initial selections,
|
@@ -291,7 +313,7 @@ module GraphQL
|
|
291
313
|
foreign_key = "_STITCH_#{boundary["selection"]}"
|
292
314
|
parent_selections = parent_op ? parent_op.selections : locale_selections
|
293
315
|
|
294
|
-
if
|
316
|
+
if parent_selections.none? { _1.is_a?(GraphQL::Language::Nodes::Field) && _1.alias == foreign_key }
|
295
317
|
foreign_key_node = GraphQL::Language::Nodes::Field.new(alias: foreign_key, name: boundary["selection"])
|
296
318
|
parent_selections << foreign_key_node << TYPENAME_NODE
|
297
319
|
end
|
@@ -335,7 +357,7 @@ module GraphQL
|
|
335
357
|
end
|
336
358
|
|
337
359
|
if expanded_selections
|
338
|
-
@supergraph.
|
360
|
+
@supergraph.memoized_schema_possible_types(parent_type.graphql_name).each do |possible_type|
|
339
361
|
next unless @supergraph.locations_by_type[possible_type.graphql_name].include?(current_location)
|
340
362
|
|
341
363
|
type_name = GraphQL::Language::Nodes::TypeName.new(name: possible_type.graphql_name)
|
@@ -352,7 +374,7 @@ module GraphQL
|
|
352
374
|
@operations_by_grouping.each do |_grouping, op|
|
353
375
|
next unless op.boundary
|
354
376
|
|
355
|
-
boundary_type = @supergraph.
|
377
|
+
boundary_type = @supergraph.memoized_schema_types[op.boundary["type_name"]]
|
356
378
|
next unless boundary_type.kind.abstract?
|
357
379
|
next if boundary_type == op.parent_type
|
358
380
|
|
@@ -87,7 +87,7 @@ module GraphQL
|
|
87
87
|
end
|
88
88
|
|
89
89
|
if operation_defs.length < 1
|
90
|
-
raise GraphQL::ExecutionError, "Invalid root operation."
|
90
|
+
raise GraphQL::ExecutionError, "Invalid root operation for given name and operation type."
|
91
91
|
elsif operation_defs.length > 1
|
92
92
|
raise GraphQL::ExecutionError, "An operation name is required when sending multiple operations."
|
93
93
|
end
|
@@ -4,14 +4,14 @@
|
|
4
4
|
module GraphQL
|
5
5
|
module Stitching
|
6
6
|
class Shaper
|
7
|
-
def initialize(
|
8
|
-
@
|
7
|
+
def initialize(supergraph:, request:)
|
8
|
+
@supergraph = supergraph
|
9
9
|
@request = request
|
10
10
|
end
|
11
11
|
|
12
12
|
def perform!(raw)
|
13
|
-
root_type = @schema.
|
14
|
-
resolve_object_scope(raw, root_type, @request.operation.selections)
|
13
|
+
@root_type = @supergraph.schema.root_type_for_operation(@request.operation.operation_type)
|
14
|
+
resolve_object_scope(raw, @root_type, @request.operation.selections, @root_type.graphql_name)
|
15
15
|
end
|
16
16
|
|
17
17
|
private
|
@@ -25,11 +25,14 @@ module GraphQL
|
|
25
25
|
selections.each do |node|
|
26
26
|
case node
|
27
27
|
when GraphQL::Language::Nodes::Field
|
28
|
-
next if node.name.start_with?("__")
|
29
|
-
|
30
28
|
field_name = node.alias || node.name
|
31
|
-
|
32
|
-
|
29
|
+
|
30
|
+
next if introspection_field?(parent_type, node) do |is_root_typename|
|
31
|
+
raw_object[field_name] = @root_type.graphql_name if is_root_typename
|
32
|
+
end
|
33
|
+
|
34
|
+
node_type = @supergraph.memoized_schema_fields(parent_type.graphql_name)[node.name].type
|
35
|
+
named_type = node_type.unwrap
|
33
36
|
|
34
37
|
raw_object[field_name] = if node_type.list?
|
35
38
|
resolve_list_scope(raw_object[field_name], Util.unwrap_non_null(node_type), node.selections)
|
@@ -42,7 +45,7 @@ module GraphQL
|
|
42
45
|
return nil if raw_object[field_name].nil? && node_type.non_null?
|
43
46
|
|
44
47
|
when GraphQL::Language::Nodes::InlineFragment
|
45
|
-
fragment_type = @
|
48
|
+
fragment_type = @supergraph.memoized_schema_types[node.type.name]
|
46
49
|
next unless typename_in_type?(typename, fragment_type)
|
47
50
|
|
48
51
|
result = resolve_object_scope(raw_object, fragment_type, node.selections, typename)
|
@@ -50,7 +53,7 @@ module GraphQL
|
|
50
53
|
|
51
54
|
when GraphQL::Language::Nodes::FragmentSpread
|
52
55
|
fragment = @request.fragment_definitions[node.name]
|
53
|
-
fragment_type = @
|
56
|
+
fragment_type = @supergraph.memoized_schema_types[fragment.type.name]
|
54
57
|
next unless typename_in_type?(typename, fragment_type)
|
55
58
|
|
56
59
|
result = resolve_object_scope(raw_object, fragment_type, fragment.selections, typename)
|
@@ -93,9 +96,27 @@ module GraphQL
|
|
93
96
|
resolved_list
|
94
97
|
end
|
95
98
|
|
99
|
+
def introspection_field?(parent_type, node)
|
100
|
+
return false unless node.name.start_with?("__")
|
101
|
+
is_root = parent_type == @root_type
|
102
|
+
|
103
|
+
case node.name
|
104
|
+
when "__typename"
|
105
|
+
yield(is_root)
|
106
|
+
true
|
107
|
+
when "__schema", "__type"
|
108
|
+
is_root && @request.operation.operation_type == "query"
|
109
|
+
else
|
110
|
+
false
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
96
114
|
def typename_in_type?(typename, type)
|
97
115
|
return true if type.graphql_name == typename
|
98
|
-
|
116
|
+
|
117
|
+
type.kind.abstract? && @supergraph.memoized_schema_possible_types(type.graphql_name).any? do |t|
|
118
|
+
t.graphql_name == typename
|
119
|
+
end
|
99
120
|
end
|
100
121
|
end
|
101
122
|
end
|
@@ -46,6 +46,8 @@ module GraphQL
|
|
46
46
|
@boundaries = boundaries
|
47
47
|
@possible_keys_by_type = {}
|
48
48
|
@possible_keys_by_type_and_location = {}
|
49
|
+
@memoized_schema_possible_types = {}
|
50
|
+
@memoized_schema_fields = {}
|
49
51
|
|
50
52
|
# add introspection types into the fields mapping
|
51
53
|
@locations_by_type_and_field = INTROSPECTION_TYPES.each_with_object(fields) do |type_name, memo|
|
@@ -81,6 +83,28 @@ module GraphQL
|
|
81
83
|
}
|
82
84
|
end
|
83
85
|
|
86
|
+
def memoized_schema_types
|
87
|
+
@memoized_schema_types ||= @schema.types
|
88
|
+
end
|
89
|
+
|
90
|
+
def memoized_schema_possible_types(type_name)
|
91
|
+
@memoized_schema_possible_types[type_name] ||= @schema.possible_types(memoized_schema_types[type_name])
|
92
|
+
end
|
93
|
+
|
94
|
+
def memoized_schema_fields(type_name)
|
95
|
+
@memoized_schema_fields[type_name] ||= begin
|
96
|
+
fields = memoized_schema_types[type_name].fields
|
97
|
+
fields["__typename"] = @schema.introspection_system.dynamic_field(name: "__typename")
|
98
|
+
|
99
|
+
if type_name == @schema.query.graphql_name
|
100
|
+
fields["__schema"] = @schema.introspection_system.entry_point(name: "__schema")
|
101
|
+
fields["__type"] = @schema.introspection_system.entry_point(name: "__type")
|
102
|
+
end
|
103
|
+
|
104
|
+
fields
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
84
108
|
def execute_at_location(location, source, variables, context)
|
85
109
|
executable = executables[location]
|
86
110
|
|
@@ -37,15 +37,6 @@ module GraphQL
|
|
37
37
|
structure
|
38
38
|
end
|
39
39
|
|
40
|
-
# gets a named type for a field node, including hidden root introspections
|
41
|
-
def self.named_type_for_field_node(schema, parent_type, node)
|
42
|
-
if node.name == "__schema" && parent_type == schema.query
|
43
|
-
schema.types["__Schema"]
|
44
|
-
else
|
45
|
-
parent_type.fields[node.name].type.unwrap
|
46
|
-
end
|
47
|
-
end
|
48
|
-
|
49
40
|
# expands interfaces and unions to an array of their memberships
|
50
41
|
# like `schema.possible_types`, but includes child interfaces
|
51
42
|
def self.expand_abstract_type(schema, parent_type)
|
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.3.
|
4
|
+
version: 0.3.4
|
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-03-
|
11
|
+
date: 2023-03-27 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: graphql
|
@@ -86,6 +86,7 @@ files:
|
|
86
86
|
- docs/images/library.png
|
87
87
|
- docs/images/merging.png
|
88
88
|
- docs/images/stitching.png
|
89
|
+
- docs/mechanics.md
|
89
90
|
- docs/planner.md
|
90
91
|
- docs/request.md
|
91
92
|
- docs/supergraph.md
|