graphql-stitching 1.2.4 → 1.2.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +11 -11
- data/docs/mechanics.md +42 -0
- data/lib/graphql/stitching/composer/validate_boundaries.rb +28 -23
- data/lib/graphql/stitching/composer.rb +1 -0
- data/lib/graphql/stitching/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 754e7a61df541f685ef50ec79039df9742e4a2589a30eadf93125d6e4ef47aed
|
4
|
+
data.tar.gz: e2ceb89be07b42dbffeaa75416055fa62ffafa001088fd47b2b4f156aee6123a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 561ecdc36e78b31e4f4fd2f85d4ff5e0f1ab23d44570de7d8ffe63c2e46b5856214eb07c25b98eafbd64857602ebd839e187175e9e3ec347caedf4651a00d332
|
7
|
+
data.tar.gz: dd7a98e1c97f7bd5a6ca2585c34b6538e94b6dfaaa83edf9b3f1bc287a79d04f62a29ebe2a6b6fae4d06e8af9f08bb20203a89f1b062f24dc622a66973f87abc
|
data/README.md
CHANGED
@@ -16,7 +16,7 @@ GraphQL stitching composes a single schema from multiple underlying GraphQL reso
|
|
16
16
|
- Computed fields (ie: federation-style `@requires`).
|
17
17
|
- Subscriptions, defer/stream.
|
18
18
|
|
19
|
-
This Ruby implementation is a sibling to [GraphQL Tools](https://the-guild.dev/graphql/stitching) (JS) and [Bramble](https://movio.github.io/bramble/) (Go), and its capabilities fall somewhere in between them. GraphQL stitching is similar in concept to [Apollo Federation](https://www.apollographql.com/docs/federation/), though more generic. The opportunity here is for a Ruby application to stitch its local schemas together or onto remote sources without requiring an additional proxy service running in another language. If your goal is to build a purely high-
|
19
|
+
This Ruby implementation is a sibling to [GraphQL Tools](https://the-guild.dev/graphql/stitching) (JS) and [Bramble](https://movio.github.io/bramble/) (Go), and its capabilities fall somewhere in between them. GraphQL stitching is similar in concept to [Apollo Federation](https://www.apollographql.com/docs/federation/), though more generic. The opportunity here is for a Ruby application to stitch its local schemas together or onto remote sources without requiring an additional proxy service running in another language. If your goal is to build a purely high-throughput federated reverse proxy, consider not using Ruby.
|
20
20
|
|
21
21
|
## Getting started
|
22
22
|
|
@@ -153,7 +153,7 @@ type Query {
|
|
153
153
|
* 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.
|
154
154
|
* 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](#multiple-query-arguments) later).
|
155
155
|
|
156
|
-
Each location that provides a unique variant of a type must provide at least one
|
156
|
+
Each location that provides a unique variant of a type must provide at least one resolver query for the type. The exception to this requirement are [outbound-only types](./docs/mechanics.md#outbound-only-merged-types) and/or [foreign key types](./docs/mechanics.md##modeling-foreign-keys-for-stitching) that contain no exclusive data:
|
157
157
|
|
158
158
|
```graphql
|
159
159
|
type Product {
|
@@ -161,11 +161,11 @@ type Product {
|
|
161
161
|
}
|
162
162
|
```
|
163
163
|
|
164
|
-
The above representation of a `Product` type
|
164
|
+
The above representation of a `Product` type contains nothing but a key that is available in other locations. Therefore, this representation will never require an inbound request to fetch it, and its resolver query may be omitted.
|
165
165
|
|
166
166
|
#### List queries
|
167
167
|
|
168
|
-
It's okay ([even preferable](#batching) in most circumstances) to provide a list accessor as a
|
168
|
+
It's okay ([even preferable](#batching) in most circumstances) to provide a list accessor as a resolver query. The only requirement is that both the field argument and return type must be lists, and the query results are expected to be a mapped set with `null` holding the position of missing results.
|
169
169
|
|
170
170
|
```graphql
|
171
171
|
type Query {
|
@@ -180,7 +180,7 @@ See [error handling](./docs/mechanics.md#stitched-errors) tips for list queries.
|
|
180
180
|
|
181
181
|
#### Abstract queries
|
182
182
|
|
183
|
-
It's okay for
|
183
|
+
It's okay for resolver queries to be implemented through abstract types. An abstract query will provide access to all of its possible types by default, each of which must implement the key.
|
184
184
|
|
185
185
|
```graphql
|
186
186
|
interface Node {
|
@@ -214,14 +214,14 @@ type Query {
|
|
214
214
|
|
215
215
|
#### Multiple query arguments
|
216
216
|
|
217
|
-
Stitching infers which argument to use for queries with a single argument, or when the key name matches its intended argument. For queries that accept multiple arguments with unmatched names, the key
|
217
|
+
Stitching infers which argument to use for queries with a single argument, or when the key name matches its intended argument. For queries that accept multiple arguments with unmatched names, the key should provide an argument alias specified as `"<arg>:<key>"`.
|
218
218
|
|
219
219
|
```graphql
|
220
220
|
type Product {
|
221
221
|
id: ID!
|
222
222
|
}
|
223
223
|
type Query {
|
224
|
-
product(
|
224
|
+
product(byId: ID, bySku: ID): Product @stitch(key: "byId:id")
|
225
225
|
}
|
226
226
|
```
|
227
227
|
|
@@ -235,7 +235,7 @@ type Product { id:ID! sku:ID! } # products location
|
|
235
235
|
type Product { sku:ID! } # catelog location
|
236
236
|
```
|
237
237
|
|
238
|
-
In the above graph, the `storefronts` and `catelog` locations have different keys that join through an intermediary. This pattern is perfectly valid and resolvable as long as the intermediary provides
|
238
|
+
In the above graph, the `storefronts` and `catelog` locations have different keys that join through an intermediary. This pattern is perfectly valid and resolvable as long as the intermediary provides resolver queries for each possible key:
|
239
239
|
|
240
240
|
```graphql
|
241
241
|
type Product {
|
@@ -244,7 +244,7 @@ type Product {
|
|
244
244
|
}
|
245
245
|
type Query {
|
246
246
|
productById(id: ID!): Product @stitch(key: "id")
|
247
|
-
|
247
|
+
productBySku(sku: ID!): Product @stitch(key: "sku")
|
248
248
|
}
|
249
249
|
```
|
250
250
|
|
@@ -294,7 +294,7 @@ sdl_string = <<~GRAPHQL
|
|
294
294
|
}
|
295
295
|
type Query {
|
296
296
|
productById(id: ID!): Product
|
297
|
-
|
297
|
+
productBySku(sku: ID!): Product
|
298
298
|
}
|
299
299
|
GRAPHQL
|
300
300
|
|
@@ -304,7 +304,7 @@ supergraph = GraphQL::Stitching::Composer.new.perform({
|
|
304
304
|
executable: ->() { ... },
|
305
305
|
stitch: [
|
306
306
|
{ field_name: "productById", key: "id" },
|
307
|
-
{ field_name: "
|
307
|
+
{ field_name: "productBySku", key: "sku" },
|
308
308
|
]
|
309
309
|
},
|
310
310
|
# ...
|
data/docs/mechanics.md
CHANGED
@@ -303,3 +303,45 @@ And produces this result:
|
|
303
303
|
```
|
304
304
|
|
305
305
|
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.
|
306
|
+
|
307
|
+
### Outbound-only merged types
|
308
|
+
|
309
|
+
Merged types do not always require a resolver query. For example:
|
310
|
+
|
311
|
+
```graphql
|
312
|
+
# -- Location A
|
313
|
+
|
314
|
+
type Widget {
|
315
|
+
id: ID!
|
316
|
+
name: String
|
317
|
+
}
|
318
|
+
|
319
|
+
type Query {
|
320
|
+
widgetA(id: ID!): Widget @stitch(key: "id")
|
321
|
+
}
|
322
|
+
|
323
|
+
# -- Location B
|
324
|
+
|
325
|
+
type Widget {
|
326
|
+
id: ID!
|
327
|
+
size: Float
|
328
|
+
}
|
329
|
+
|
330
|
+
type Query {
|
331
|
+
widgetB(id: ID!): Widget @stitch(key: "id")
|
332
|
+
}
|
333
|
+
|
334
|
+
# -- Location C
|
335
|
+
|
336
|
+
type Widget {
|
337
|
+
id: ID!
|
338
|
+
name: String
|
339
|
+
size: Float
|
340
|
+
}
|
341
|
+
|
342
|
+
type Query {
|
343
|
+
featuredWidget: Widget
|
344
|
+
}
|
345
|
+
```
|
346
|
+
|
347
|
+
In this graph, `Widget` is a merged type without a resolver query in location C. This works because all of its fields are resolvable in other locations; that means location C can provide outbound representations of this type without ever needing to resolve inbound requests for it. Outbound types do still require a key field (such as `id` above) that allow them to join with data in other resolver locations.
|
@@ -4,29 +4,29 @@ module GraphQL::Stitching
|
|
4
4
|
class Composer
|
5
5
|
class ValidateBoundaries < BaseValidator
|
6
6
|
|
7
|
-
def perform(
|
8
|
-
|
7
|
+
def perform(supergraph, composer)
|
8
|
+
supergraph.schema.types.each do |type_name, type|
|
9
9
|
# objects and interfaces that are not the root operation types
|
10
10
|
next unless type.kind.object? || type.kind.interface?
|
11
|
-
next if
|
11
|
+
next if supergraph.schema.query == type || supergraph.schema.mutation == type
|
12
12
|
next if type.graphql_name.start_with?("__")
|
13
13
|
|
14
14
|
# multiple subschemas implement the type
|
15
15
|
candidate_types_by_location = composer.candidate_types_by_name_and_location[type_name]
|
16
16
|
next unless candidate_types_by_location.length > 1
|
17
17
|
|
18
|
-
boundaries =
|
18
|
+
boundaries = supergraph.boundaries[type_name]
|
19
19
|
if boundaries&.any?
|
20
|
-
validate_as_boundary(
|
20
|
+
validate_as_boundary(supergraph, type, candidate_types_by_location, boundaries)
|
21
21
|
elsif type.kind.object?
|
22
|
-
validate_as_shared(
|
22
|
+
validate_as_shared(supergraph, 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(
|
29
|
+
def validate_as_boundary(supergraph, 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
|
|
@@ -41,25 +41,30 @@ module GraphQL::Stitching
|
|
41
41
|
memo[boundary.location][boundary.key] = boundary
|
42
42
|
end
|
43
43
|
|
44
|
-
boundary_keys = boundaries.map
|
45
|
-
|
46
|
-
|
47
|
-
|
44
|
+
boundary_keys = boundaries.map(&:key).to_set
|
45
|
+
|
46
|
+
# All non-key fields must be resolvable in at least one boundary location
|
47
|
+
supergraph.locations_by_type_and_field[type.graphql_name].each do |field_name, locations|
|
48
|
+
next if boundary_keys.include?(field_name)
|
48
49
|
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
raise Composer::ValidationError, "A boundary query is required for `#{type.graphql_name}` in #{location} because it provides unique fields."
|
50
|
+
if locations.none? { boundaries_by_location_and_key[_1] }
|
51
|
+
where = locations.length > 1 ? "one of #{locations.join(", ")} locations" : locations.first
|
52
|
+
raise Composer::ValidationError, "A boundary query is required for `#{type.graphql_name}` in #{where} to resolve field `#{field_name}`."
|
53
53
|
end
|
54
54
|
end
|
55
55
|
|
56
|
-
|
57
|
-
|
56
|
+
# All locations of a boundary type must include at least one key field
|
57
|
+
supergraph.fields_by_type_and_location[type.graphql_name].each do |location, field_names|
|
58
|
+
if field_names.none? { boundary_keys.include?(_1) }
|
59
|
+
raise Composer::ValidationError, "A boundary key is required for `#{type.graphql_name}` in #{location} to join with other locations."
|
60
|
+
end
|
61
|
+
end
|
58
62
|
|
59
63
|
# verify that all outbound locations can access all inbound locations
|
60
|
-
|
61
|
-
|
62
|
-
|
64
|
+
resolver_locations = boundaries_by_location_and_key.keys
|
65
|
+
candidate_types_by_location.each_key do |location|
|
66
|
+
remote_locations = resolver_locations.reject { _1 == location }
|
67
|
+
paths = supergraph.route_type_to_locations(type.graphql_name, location, remote_locations)
|
63
68
|
if paths.length != remote_locations.length || paths.any? { |_loc, path| path.nil? }
|
64
69
|
raise Composer::ValidationError, "Cannot route `#{type.graphql_name}` boundaries in #{location} to all other locations. "\
|
65
70
|
"All locations must provide a boundary accessor that uses a conjoining key."
|
@@ -67,7 +72,7 @@ module GraphQL::Stitching
|
|
67
72
|
end
|
68
73
|
end
|
69
74
|
|
70
|
-
def validate_as_shared(
|
75
|
+
def validate_as_shared(supergraph, type, candidate_types_by_location)
|
71
76
|
expected_fields = begin
|
72
77
|
type.fields.keys.sort
|
73
78
|
rescue StandardError => e
|
@@ -79,8 +84,8 @@ module GraphQL::Stitching
|
|
79
84
|
end
|
80
85
|
end
|
81
86
|
|
82
|
-
candidate_types_by_location.each do |location,
|
83
|
-
if
|
87
|
+
candidate_types_by_location.each do |location, candidate_type|
|
88
|
+
if candidate_type.fields.keys.sort != expected_fields
|
84
89
|
raise Composer::ValidationError, "Shared type `#{type.graphql_name}` must have consistent fields across locations, "\
|
85
90
|
"or else define boundary queries so that its unique fields may be accessed remotely."
|
86
91
|
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: 1.2.
|
4
|
+
version: 1.2.5
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Greg MacWilliam
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-05-31 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: graphql
|