graphql-stitching 0.3.1 → 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/.gitignore +2 -0
- data/README.md +17 -5
- 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 +17 -16
- data/lib/graphql/stitching/composer/validate_interfaces.rb +40 -12
- data/lib/graphql/stitching/composer.rb +61 -42
- data/lib/graphql/stitching/executor.rb +1 -3
- data/lib/graphql/stitching/gateway.rb +16 -18
- data/lib/graphql/stitching/planner.rb +30 -28
- data/lib/graphql/stitching/planner_operation.rb +4 -2
- data/lib/graphql/stitching/request.rb +1 -1
- data/lib/graphql/stitching/shaper.rb +5 -5
- data/lib/graphql/stitching/util.rb +24 -22
- 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/.gitignore
CHANGED
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.
|
@@ -32,7 +32,7 @@ require "graphql/stitching"
|
|
32
32
|
|
33
33
|
## Usage
|
34
34
|
|
35
|
-
The quickest way to start is to use the provided [`Gateway`](./docs/gateway.md) component that
|
35
|
+
The quickest way to start is to use the provided [`Gateway`](./docs/gateway.md) component that wraps a stitched graph in an executable workflow with [caching hooks](./docs/gateway.md#cache-hooks):
|
36
36
|
|
37
37
|
```ruby
|
38
38
|
movies_schema = <<~GRAPHQL
|
@@ -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,61 +12,62 @@ 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
|
|
33
33
|
# only one boundary allowed per type/location/key
|
34
34
|
boundaries_by_location_and_key = boundaries.each_with_object({}) do |boundary, memo|
|
35
35
|
if memo.dig(boundary["location"], boundary["selection"])
|
36
|
-
raise Composer::ValidationError, "Multiple boundary queries for `#{type.graphql_name}.#{boundary["selection"]}`
|
37
|
-
|
36
|
+
raise Composer::ValidationError, "Multiple boundary queries for `#{type.graphql_name}.#{boundary["selection"]}` "\
|
37
|
+
"found in #{boundary["location"]}. Limit one boundary query per type and key in each location. "\
|
38
|
+
"Abstract boundaries provide all possible types."
|
38
39
|
end
|
39
40
|
memo[boundary["location"]] ||= {}
|
40
41
|
memo[boundary["location"]][boundary["selection"]] = boundary
|
41
42
|
end
|
42
43
|
|
43
44
|
boundary_keys = boundaries.map { _1["selection"] }.uniq
|
44
|
-
key_only_types_by_location =
|
45
|
+
key_only_types_by_location = candidate_types_by_location.select do |location, subschema_type|
|
45
46
|
subschema_type.fields.keys.length == 1 && boundary_keys.include?(subschema_type.fields.keys.first)
|
46
47
|
end
|
47
48
|
|
48
49
|
# all locations have a boundary, or else are key-only
|
49
|
-
|
50
|
+
candidate_types_by_location.each do |location, subschema_type|
|
50
51
|
unless boundaries_by_location_and_key[location] || key_only_types_by_location[location]
|
51
52
|
raise Composer::ValidationError, "A boundary query is required for `#{type.graphql_name}` in #{location} because it provides unique fields."
|
52
53
|
end
|
53
54
|
end
|
54
55
|
|
55
56
|
outbound_access_locations = key_only_types_by_location.keys
|
56
|
-
bidirectional_access_locations =
|
57
|
+
bidirectional_access_locations = candidate_types_by_location.keys - outbound_access_locations
|
57
58
|
|
58
59
|
# verify that all outbound locations can access all inbound locations
|
59
60
|
(outbound_access_locations + bidirectional_access_locations).each do |location|
|
60
61
|
remote_locations = bidirectional_access_locations.reject { _1 == location }
|
61
62
|
paths = ctx.route_type_to_locations(type.graphql_name, location, remote_locations)
|
62
63
|
if paths.length != remote_locations.length || paths.any? { |_loc, path| path.nil? }
|
63
|
-
raise Composer::ValidationError, "Cannot route `#{type.graphql_name}` boundaries in #{location} to all other locations.
|
64
|
-
|
64
|
+
raise Composer::ValidationError, "Cannot route `#{type.graphql_name}` boundaries in #{location} to all other locations. "\
|
65
|
+
"All locations must provide a boundary accessor that uses a conjoining key."
|
65
66
|
end
|
66
67
|
end
|
67
68
|
end
|
68
69
|
|
69
|
-
def validate_as_shared(ctx, type,
|
70
|
+
def validate_as_shared(ctx, type, candidate_types_by_location)
|
70
71
|
expected_fields = begin
|
71
72
|
type.fields.keys.sort
|
72
73
|
rescue StandardError => e
|
@@ -78,10 +79,10 @@ module GraphQL
|
|
78
79
|
end
|
79
80
|
end
|
80
81
|
|
81
|
-
|
82
|
+
candidate_types_by_location.each do |location, subschema_type|
|
82
83
|
if subschema_type.fields.keys.sort != expected_fields
|
83
|
-
raise Composer::ValidationError, "Shared type `#{type.graphql_name}` must have consistent fields across locations,
|
84
|
-
|
84
|
+
raise Composer::ValidationError, "Shared type `#{type.graphql_name}` must have consistent fields across locations, "\
|
85
|
+
"or else define boundary queries so that its unique fields may be accessed remotely."
|
85
86
|
end
|
86
87
|
end
|
87
88
|
end
|
@@ -4,19 +4,47 @@ module GraphQL
|
|
4
4
|
module Stitching
|
5
5
|
class Composer::ValidateInterfaces < Composer::BaseValidator
|
6
6
|
|
7
|
+
# For each composed interface, check the interface against each possible type
|
8
|
+
# to assure that intersecting fields have compatible types, structures, and nullability.
|
9
|
+
# Verifies compatibility of types that inherit interface contracts through merging.
|
7
10
|
def perform(supergraph, composer)
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
11
|
+
supergraph.schema.types.each do |type_name, interface_type|
|
12
|
+
next unless interface_type.kind.interface?
|
13
|
+
|
14
|
+
supergraph.schema.possible_types(interface_type).each do |possible_type|
|
15
|
+
interface_type.fields.each do |field_name, interface_field|
|
16
|
+
# graphql-ruby will dynamically apply interface fields on a type implementation,
|
17
|
+
# so check the delegation map to assure that all materialized fields have resolver locations.
|
18
|
+
unless supergraph.locations_by_type_and_field[possible_type.graphql_name][field_name]&.any?
|
19
|
+
raise Composer::ValidationError, "Type #{possible_type.graphql_name} does not implement a `#{field_name}` field in any location, "\
|
20
|
+
"which is required by interface #{interface_type.graphql_name}."
|
21
|
+
end
|
22
|
+
|
23
|
+
intersecting_field = possible_type.fields[field_name]
|
24
|
+
interface_type_structure = Util.flatten_type_structure(interface_field.type)
|
25
|
+
possible_type_structure = Util.flatten_type_structure(intersecting_field.type)
|
26
|
+
|
27
|
+
if possible_type_structure.length != interface_type_structure.length
|
28
|
+
raise Composer::ValidationError, "Incompatible list structures between field #{possible_type.graphql_name}.#{field_name} of type "\
|
29
|
+
"#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
|
30
|
+
end
|
31
|
+
|
32
|
+
interface_type_structure.each_with_index do |interface_struct, index|
|
33
|
+
possible_struct = possible_type_structure[index]
|
34
|
+
|
35
|
+
if possible_struct[:name] != interface_struct[:name]
|
36
|
+
raise Composer::ValidationError, "Incompatible named types between field #{possible_type.graphql_name}.#{field_name} of type "\
|
37
|
+
"#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
|
38
|
+
end
|
39
|
+
|
40
|
+
if possible_struct[:null] && !interface_struct[:null]
|
41
|
+
raise Composer::ValidationError, "Incompatible nullability between field #{possible_type.graphql_name}.#{field_name} of type "\
|
42
|
+
"#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
20
48
|
end
|
21
49
|
|
22
50
|
end
|
@@ -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,10 +77,10 @@ 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
|
81
84
|
raise ComposerError, "Cannot merge different kinds for `#{type_name}`. Found: #{kinds.join(", ")}."
|
82
85
|
end
|
83
86
|
|
@@ -96,7 +99,7 @@ module GraphQL
|
|
96
99
|
when "INPUT_OBJECT"
|
97
100
|
build_input_object_type(type_name, types_by_location)
|
98
101
|
else
|
99
|
-
raise ComposerError, "Unexpected kind encountered for `#{type_name}`. Found: #{
|
102
|
+
raise ComposerError, "Unexpected kind encountered for `#{type_name}`. Found: #{kinds.first}."
|
100
103
|
end
|
101
104
|
end
|
102
105
|
|
@@ -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(
|
@@ -307,12 +311,13 @@ module GraphQL
|
|
307
311
|
fields_by_name_location.each do |field_name, fields_by_location|
|
308
312
|
value_types = fields_by_location.values.map(&:type)
|
309
313
|
|
314
|
+
type = merge_value_types(type_name, value_types, field_name: field_name)
|
310
315
|
schema_field = owner.field(
|
311
316
|
field_name,
|
312
317
|
description: merge_descriptions(type_name, fields_by_location, field_name: field_name),
|
313
318
|
deprecation_reason: merge_deprecations(type_name, fields_by_location, field_name: field_name),
|
314
|
-
type:
|
315
|
-
null: !
|
319
|
+
type: Util.unwrap_non_null(type),
|
320
|
+
null: !type.non_null?,
|
316
321
|
camelize: false,
|
317
322
|
)
|
318
323
|
|
@@ -345,12 +350,13 @@ module GraphQL
|
|
345
350
|
# Getting double args sometimes... why?
|
346
351
|
return if owner.arguments.any? { _1.first == argument_name }
|
347
352
|
|
353
|
+
type = merge_value_types(type_name, value_types, argument_name: argument_name, field_name: field_name)
|
348
354
|
schema_argument = owner.argument(
|
349
355
|
argument_name,
|
350
356
|
description: merge_descriptions(type_name, arguments_by_location, argument_name: argument_name, field_name: field_name),
|
351
357
|
deprecation_reason: merge_deprecations(type_name, arguments_by_location, argument_name: argument_name, field_name: field_name),
|
352
|
-
type:
|
353
|
-
required:
|
358
|
+
type: Util.unwrap_non_null(type),
|
359
|
+
required: type.non_null?,
|
354
360
|
camelize: false,
|
355
361
|
)
|
356
362
|
|
@@ -401,37 +407,32 @@ module GraphQL
|
|
401
407
|
|
402
408
|
def merge_value_types(type_name, type_candidates, field_name: nil, argument_name: nil)
|
403
409
|
path = [type_name, field_name, argument_name].compact.join(".")
|
404
|
-
|
410
|
+
alt_structures = type_candidates.map { Util.flatten_type_structure(_1) }
|
411
|
+
basis_structure = alt_structures.shift
|
405
412
|
|
406
|
-
|
407
|
-
|
408
|
-
end
|
409
|
-
|
410
|
-
type = GraphQL::Schema::BUILT_IN_TYPES.fetch(named_types.first, build_type_binding(named_types.first))
|
411
|
-
list_structures = type_candidates.map { Util.get_list_structure(_1) }
|
412
|
-
|
413
|
-
if list_structures.any?(&:any?)
|
414
|
-
if list_structures.any? { _1.length != list_structures.first.length }
|
413
|
+
alt_structures.each do |alt_structure|
|
414
|
+
if alt_structure.length != basis_structure.length
|
415
415
|
raise ComposerError, "Cannot compose mixed list structures at `#{path}`."
|
416
416
|
end
|
417
417
|
|
418
|
-
|
419
|
-
|
420
|
-
# input arguments use strongest nullability, readonly fields use weakest
|
421
|
-
non_null = list_structures.public_send(argument_name ? :any? : :all?) do |list_structure|
|
422
|
-
list_structure[index].start_with?("non_null")
|
423
|
-
end
|
424
|
-
|
425
|
-
case current
|
426
|
-
when "list", "non_null_list"
|
427
|
-
type = type.to_list_type
|
428
|
-
type = type.to_non_null_type if non_null
|
429
|
-
when "element", "non_null_element"
|
430
|
-
type = type.to_non_null_type if non_null
|
431
|
-
end
|
418
|
+
if alt_structure.last[:name] != basis_structure.last[:name]
|
419
|
+
raise ComposerError, "Cannot compose mixed types at `#{path}`."
|
432
420
|
end
|
433
421
|
end
|
434
422
|
|
423
|
+
type = GraphQL::Schema::BUILT_IN_TYPES.fetch(
|
424
|
+
basis_structure.last[:name],
|
425
|
+
build_type_binding(basis_structure.last[:name])
|
426
|
+
)
|
427
|
+
|
428
|
+
basis_structure.reverse!.each_with_index do |basis, index|
|
429
|
+
rev_index = basis_structure.length - index - 1
|
430
|
+
non_null = alt_structures.each_with_object([!basis[:null]]) { |s, m| m << !s[rev_index][:null] }
|
431
|
+
|
432
|
+
type = type.to_list_type if basis[:list]
|
433
|
+
type = type.to_non_null_type if argument_name ? non_null.any? : non_null.all?
|
434
|
+
end
|
435
|
+
|
435
436
|
type
|
436
437
|
end
|
437
438
|
|
@@ -459,7 +460,7 @@ module GraphQL
|
|
459
460
|
types_by_location.each do |location, type_candidate|
|
460
461
|
type_candidate.fields.each do |field_name, field_candidate|
|
461
462
|
boundary_type_name = field_candidate.type.unwrap.graphql_name
|
462
|
-
|
463
|
+
boundary_structure = Util.flatten_type_structure(field_candidate.type)
|
463
464
|
|
464
465
|
field_candidate.directives.each do |directive|
|
465
466
|
next unless directive.graphql_name == GraphQL::Stitching.stitch_directive
|
@@ -482,8 +483,8 @@ module GraphQL
|
|
482
483
|
raise ComposerError, "Invalid boundary argument `#{argument_name}` for #{type_name}.#{field_name}."
|
483
484
|
end
|
484
485
|
|
485
|
-
|
486
|
-
if
|
486
|
+
argument_structure = Util.flatten_type_structure(argument.type)
|
487
|
+
if argument_structure.length != boundary_structure.length
|
487
488
|
raise ComposerError, "Mismatched input/output for #{type_name}.#{field_name}.#{argument_name} boundary. Arguments must map directly to results."
|
488
489
|
end
|
489
490
|
|
@@ -493,7 +494,7 @@ module GraphQL
|
|
493
494
|
"selection" => key_selections[0].name,
|
494
495
|
"field" => field_candidate.name,
|
495
496
|
"arg" => argument_name,
|
496
|
-
"list" =>
|
497
|
+
"list" => boundary_structure.first[:list],
|
497
498
|
"type_name" => boundary_type_name,
|
498
499
|
}
|
499
500
|
end
|
@@ -501,13 +502,31 @@ module GraphQL
|
|
501
502
|
end
|
502
503
|
end
|
503
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
|
+
|
504
523
|
def expand_abstract_boundaries(schema)
|
505
524
|
@boundary_map.keys.each do |type_name|
|
506
525
|
boundary_type = schema.types[type_name]
|
507
526
|
next unless boundary_type.kind.abstract?
|
508
527
|
|
509
528
|
expanded_types = Util.expand_abstract_type(schema, boundary_type)
|
510
|
-
expanded_types.select { @
|
529
|
+
expanded_types.select { @candidate_types_by_name_and_location[_1.graphql_name].length > 1 }.each do |expanded_type|
|
511
530
|
@boundary_map[expanded_type.graphql_name] ||= []
|
512
531
|
@boundary_map[expanded_type.graphql_name].push(*@boundary_map[type_name])
|
513
532
|
end
|
@@ -558,7 +577,7 @@ module GraphQL
|
|
558
577
|
@field_map = {}
|
559
578
|
@boundary_map = {}
|
560
579
|
@mapped_type_names = {}
|
561
|
-
@
|
580
|
+
@candidate_directives_by_name_and_location = nil
|
562
581
|
@schema_directives = nil
|
563
582
|
end
|
564
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"]
|
@@ -37,23 +37,23 @@ module GraphQL
|
|
37
37
|
|
38
38
|
request.prepare!
|
39
39
|
|
40
|
-
|
41
|
-
|
42
|
-
GraphQL::Stitching::Planner.new(
|
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 }
|
@@ -20,13 +20,11 @@ module GraphQL
|
|
20
20
|
end
|
21
21
|
|
22
22
|
def operations
|
23
|
-
|
24
|
-
ops.sort_by!(&:key)
|
25
|
-
ops
|
23
|
+
@operations_by_grouping.values.sort_by!(&:key)
|
26
24
|
end
|
27
25
|
|
28
26
|
def to_h
|
29
|
-
{ "ops" => operations.map(&:to_h) }
|
27
|
+
{ "ops" => operations.map!(&:to_h) }
|
30
28
|
end
|
31
29
|
|
32
30
|
private
|
@@ -41,10 +39,8 @@ module GraphQL
|
|
41
39
|
|
42
40
|
selections_by_location = @request.operation.selections.each_with_object({}) do |node, memo|
|
43
41
|
locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name] || SUPERGRAPH_LOCATIONS
|
44
|
-
|
45
|
-
|
46
|
-
memo[locations.last] ||= []
|
47
|
-
memo[locations.last] << node
|
42
|
+
memo[locations.first] ||= []
|
43
|
+
memo[locations.first] << node
|
48
44
|
end
|
49
45
|
|
50
46
|
selections_by_location.each do |location, selections|
|
@@ -55,8 +51,7 @@ module GraphQL
|
|
55
51
|
parent_type = @supergraph.schema.mutation
|
56
52
|
|
57
53
|
location_groups = @request.operation.selections.each_with_object([]) do |node, memo|
|
58
|
-
|
59
|
-
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
|
60
55
|
|
61
56
|
if memo.none? || memo.last[:location] != next_location
|
62
57
|
memo << { location: next_location, selections: [] }
|
@@ -221,7 +216,7 @@ module GraphQL
|
|
221
216
|
possible_locations_by_field = @supergraph.locations_by_type_and_field[parent_type.graphql_name]
|
222
217
|
selections_by_location = {}
|
223
218
|
|
224
|
-
# distribute unique fields among required locations
|
219
|
+
# 1. distribute unique fields among required locations
|
225
220
|
remote_selections.reject! do |node|
|
226
221
|
possible_locations = possible_locations_by_field[node.name]
|
227
222
|
if possible_locations.length == 1
|
@@ -231,13 +226,22 @@ module GraphQL
|
|
231
226
|
end
|
232
227
|
end
|
233
228
|
|
234
|
-
# 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
|
235
241
|
if remote_selections.any?
|
236
|
-
|
237
|
-
location_weights = if remote_selections.length > 1
|
242
|
+
field_count_by_location = if remote_selections.length > 1
|
238
243
|
remote_selections.each_with_object({}) do |node, memo|
|
239
|
-
|
240
|
-
possible_locations.each do |location|
|
244
|
+
possible_locations_by_field[node.name].each do |location|
|
241
245
|
memo[location] ||= 0
|
242
246
|
memo[location] += 1
|
243
247
|
end
|
@@ -248,18 +252,16 @@ module GraphQL
|
|
248
252
|
|
249
253
|
remote_selections.each do |node|
|
250
254
|
possible_locations = possible_locations_by_field[node.name]
|
251
|
-
|
255
|
+
preferred_location = possible_locations.first
|
252
256
|
|
253
|
-
|
254
|
-
|
255
|
-
score = selections_by_location[possible_location] ? remote_selections.length : 0
|
256
|
-
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)
|
257
259
|
|
258
|
-
if
|
259
|
-
|
260
|
-
|
260
|
+
if available_fields > max_availability
|
261
|
+
preferred_location = possible_location
|
262
|
+
available_fields
|
261
263
|
else
|
262
|
-
|
264
|
+
max_availability
|
263
265
|
end
|
264
266
|
end
|
265
267
|
|
@@ -268,14 +270,14 @@ module GraphQL
|
|
268
270
|
end
|
269
271
|
end
|
270
272
|
|
273
|
+
# route from current location to target locations via boundary queries,
|
274
|
+
# then translate those routes into planner operations
|
271
275
|
routes = @supergraph.route_type_to_locations(parent_type.graphql_name, current_location, selections_by_location.keys)
|
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
|
@@ -3,6 +3,8 @@
|
|
3
3
|
module GraphQL
|
4
4
|
module Stitching
|
5
5
|
class PlannerOperation
|
6
|
+
LANGUAGE_PRINTER = GraphQL::Language::Printer.new
|
7
|
+
|
6
8
|
attr_reader :key, :location, :parent_type, :type_condition, :operation_type, :insertion_path
|
7
9
|
attr_accessor :after_key, :selections, :variables, :boundary
|
8
10
|
|
@@ -32,12 +34,12 @@ module GraphQL
|
|
32
34
|
|
33
35
|
def selection_set
|
34
36
|
op = GraphQL::Language::Nodes::OperationDefinition.new(selections: @selections)
|
35
|
-
|
37
|
+
LANGUAGE_PRINTER.print(op).gsub!(/\s+/, " ").strip!
|
36
38
|
end
|
37
39
|
|
38
40
|
def variable_set
|
39
41
|
@variables.each_with_object({}) do |(variable_name, value_type), memo|
|
40
|
-
memo[variable_name] =
|
42
|
+
memo[variable_name] = LANGUAGE_PRINTER.print(value_type)
|
41
43
|
end
|
42
44
|
end
|
43
45
|
|
@@ -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
|
@@ -43,7 +43,7 @@ module GraphQL
|
|
43
43
|
|
44
44
|
when GraphQL::Language::Nodes::InlineFragment
|
45
45
|
fragment_type = @schema.types[node.type.name]
|
46
|
-
next unless
|
46
|
+
next unless typename_in_type?(typename, fragment_type)
|
47
47
|
|
48
48
|
result = resolve_object_scope(raw_object, fragment_type, node.selections, typename)
|
49
49
|
return nil if result.nil?
|
@@ -51,7 +51,7 @@ module GraphQL
|
|
51
51
|
when GraphQL::Language::Nodes::FragmentSpread
|
52
52
|
fragment = @request.fragment_definitions[node.name]
|
53
53
|
fragment_type = @schema.types[fragment.type.name]
|
54
|
-
next unless
|
54
|
+
next unless typename_in_type?(typename, fragment_type)
|
55
55
|
|
56
56
|
result = resolve_object_scope(raw_object, fragment_type, fragment.selections, typename)
|
57
57
|
return nil if result.nil?
|
@@ -93,9 +93,9 @@ module GraphQL
|
|
93
93
|
resolved_list
|
94
94
|
end
|
95
95
|
|
96
|
-
def
|
97
|
-
return true if
|
98
|
-
|
96
|
+
def typename_in_type?(typename, type)
|
97
|
+
return true if type.graphql_name == typename
|
98
|
+
type.kind.abstract? && @schema.possible_types(type).any? { _1.graphql_name == typename }
|
99
99
|
end
|
100
100
|
end
|
101
101
|
end
|
@@ -10,12 +10,33 @@ module GraphQL
|
|
10
10
|
|
11
11
|
# strips non-null wrappers from a type
|
12
12
|
def self.unwrap_non_null(type)
|
13
|
-
while type.
|
14
|
-
type = type.of_type
|
15
|
-
end
|
13
|
+
type = type.of_type while type.non_null?
|
16
14
|
type
|
17
15
|
end
|
18
16
|
|
17
|
+
# builds a single-dimensional representation of a wrapped type structure
|
18
|
+
def self.flatten_type_structure(type)
|
19
|
+
structure = []
|
20
|
+
|
21
|
+
while type.list?
|
22
|
+
structure << {
|
23
|
+
list: true,
|
24
|
+
null: !type.non_null?,
|
25
|
+
name: nil,
|
26
|
+
}
|
27
|
+
|
28
|
+
type = unwrap_non_null(type).of_type
|
29
|
+
end
|
30
|
+
|
31
|
+
structure << {
|
32
|
+
list: false,
|
33
|
+
null: !type.non_null?,
|
34
|
+
name: type.unwrap.graphql_name,
|
35
|
+
}
|
36
|
+
|
37
|
+
structure
|
38
|
+
end
|
39
|
+
|
19
40
|
# gets a named type for a field node, including hidden root introspections
|
20
41
|
def self.named_type_for_field_node(schema, parent_type, node)
|
21
42
|
if node.name == "__schema" && parent_type == schema.query
|
@@ -40,25 +61,6 @@ module GraphQL
|
|
40
61
|
end
|
41
62
|
result.uniq
|
42
63
|
end
|
43
|
-
|
44
|
-
# gets a deep structural description of a list value type
|
45
|
-
def self.get_list_structure(type)
|
46
|
-
structure = []
|
47
|
-
previous = nil
|
48
|
-
while type.respond_to?(:of_type)
|
49
|
-
if type.is_a?(GraphQL::Schema::List)
|
50
|
-
structure.push(previous.is_a?(GraphQL::Schema::NonNull) ? "non_null_list" : "list")
|
51
|
-
end
|
52
|
-
if structure.any?
|
53
|
-
previous = type
|
54
|
-
if !type.of_type.respond_to?(:of_type)
|
55
|
-
structure.push(previous.is_a?(GraphQL::Schema::NonNull) ? "non_null_element" : "element")
|
56
|
-
end
|
57
|
-
end
|
58
|
-
type = type.of_type
|
59
|
-
end
|
60
|
-
structure
|
61
|
-
end
|
62
64
|
end
|
63
65
|
end
|
64
66
|
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
|