graphql-stitching 0.3.2 → 0.3.3
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 +1 -3
- data/lib/graphql/stitching/gateway.rb +17 -19
- data/lib/graphql/stitching/planner.rb +26 -24
- data/lib/graphql/stitching/request.rb +1 -1
- 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: b07a54288795f5b321029340a61e97dd9209d395712dc9ac5921ebd5c52523a1
|
4
|
+
data.tar.gz: 83e4dea46c7b4ff7a3716c2936773893df58e732a50c36ee1a5af8e4e5cab4bd
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 831a54675315529b6bc1d489296a15325f88bcccbbb8f480c786641410de853989425591f4e60a6249ef123a3aa9e204780388925f2a027d758ac5594ca6c306
|
7
|
+
data.tar.gz: f66ab6dfc996aa9982f6c5d09e2bf98b5a5de8b85220ea839f854e53edd3f55144404b5ddff91f2681f7d8fee21071bb54f20265951424840c527b7203dd9ace
|
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"]
|
@@ -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 }
|
@@ -39,10 +39,8 @@ module GraphQL
|
|
39
39
|
|
40
40
|
selections_by_location = @request.operation.selections.each_with_object({}) do |node, memo|
|
41
41
|
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
|
42
|
+
memo[locations.first] ||= []
|
43
|
+
memo[locations.first] << node
|
46
44
|
end
|
47
45
|
|
48
46
|
selections_by_location.each do |location, selections|
|
@@ -53,8 +51,7 @@ module GraphQL
|
|
53
51
|
parent_type = @supergraph.schema.mutation
|
54
52
|
|
55
53
|
location_groups = @request.operation.selections.each_with_object([]) do |node, memo|
|
56
|
-
|
57
|
-
next_location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].last
|
54
|
+
next_location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].first
|
58
55
|
|
59
56
|
if memo.none? || memo.last[:location] != next_location
|
60
57
|
memo << { location: next_location, selections: [] }
|
@@ -219,7 +216,7 @@ module GraphQL
|
|
219
216
|
possible_locations_by_field = @supergraph.locations_by_type_and_field[parent_type.graphql_name]
|
220
217
|
selections_by_location = {}
|
221
218
|
|
222
|
-
# distribute unique fields among required locations
|
219
|
+
# 1. distribute unique fields among required locations
|
223
220
|
remote_selections.reject! do |node|
|
224
221
|
possible_locations = possible_locations_by_field[node.name]
|
225
222
|
if possible_locations.length == 1
|
@@ -229,13 +226,22 @@ module GraphQL
|
|
229
226
|
end
|
230
227
|
end
|
231
228
|
|
232
|
-
# distribute non-unique fields among
|
229
|
+
# 2. distribute non-unique fields among locations that are already used
|
230
|
+
if selections_by_location.any? && remote_selections.any?
|
231
|
+
remote_selections.reject! do |node|
|
232
|
+
used_location = possible_locations_by_field[node.name].find { selections_by_location[_1] }
|
233
|
+
if used_location
|
234
|
+
selections_by_location[used_location] << node
|
235
|
+
true
|
236
|
+
end
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
# 3. distribute remaining fields among locations weighted by greatest availability
|
233
241
|
if remote_selections.any?
|
234
|
-
|
235
|
-
location_weights = if remote_selections.length > 1
|
242
|
+
field_count_by_location = if remote_selections.length > 1
|
236
243
|
remote_selections.each_with_object({}) do |node, memo|
|
237
|
-
|
238
|
-
possible_locations.each do |location|
|
244
|
+
possible_locations_by_field[node.name].each do |location|
|
239
245
|
memo[location] ||= 0
|
240
246
|
memo[location] += 1
|
241
247
|
end
|
@@ -246,18 +252,16 @@ module GraphQL
|
|
246
252
|
|
247
253
|
remote_selections.each do |node|
|
248
254
|
possible_locations = possible_locations_by_field[node.name]
|
249
|
-
|
255
|
+
preferred_location = possible_locations.first
|
250
256
|
|
251
|
-
|
252
|
-
|
253
|
-
score = selections_by_location[possible_location] ? remote_selections.length : 0
|
254
|
-
score += location_weights.fetch(possible_location, 0)
|
257
|
+
possible_locations.reduce(0) do |max_availability, possible_location|
|
258
|
+
available_fields = field_count_by_location.fetch(possible_location, 0)
|
255
259
|
|
256
|
-
if
|
257
|
-
|
258
|
-
|
260
|
+
if available_fields > max_availability
|
261
|
+
preferred_location = possible_location
|
262
|
+
available_fields
|
259
263
|
else
|
260
|
-
|
264
|
+
max_availability
|
261
265
|
end
|
262
266
|
end
|
263
267
|
|
@@ -272,10 +276,8 @@ module GraphQL
|
|
272
276
|
routes.values.each_with_object({}) do |route, ops_by_location|
|
273
277
|
route.reduce(nil) do |parent_op, boundary|
|
274
278
|
location = boundary["location"]
|
275
|
-
new_operation = false
|
276
279
|
|
277
280
|
unless op = ops_by_location[location]
|
278
|
-
new_operation = true
|
279
281
|
op = ops_by_location[location] = add_operation(
|
280
282
|
location: location,
|
281
283
|
# routing locations added as intermediaries have no initial selections,
|
@@ -291,7 +293,7 @@ module GraphQL
|
|
291
293
|
foreign_key = "_STITCH_#{boundary["selection"]}"
|
292
294
|
parent_selections = parent_op ? parent_op.selections : locale_selections
|
293
295
|
|
294
|
-
if
|
296
|
+
if parent_selections.none? { _1.is_a?(GraphQL::Language::Nodes::Field) && _1.alias == foreign_key }
|
295
297
|
foreign_key_node = GraphQL::Language::Nodes::Field.new(alias: foreign_key, name: boundary["selection"])
|
296
298
|
parent_selections << foreign_key_node << TYPENAME_NODE
|
297
299
|
end
|
@@ -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
|
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.3
|
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-24 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
|