graphql-stitching 1.2.5 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +67 -17
- data/docs/README.md +2 -1
- data/docs/mechanics.md +2 -1
- data/docs/resolver.md +101 -0
- data/lib/graphql/stitching/client.rb +5 -1
- data/lib/graphql/stitching/composer/{boundary_config.rb → resolver_config.rb} +18 -13
- data/lib/graphql/stitching/composer/validate_interfaces.rb +4 -4
- data/lib/graphql/stitching/composer/validate_resolvers.rb +97 -0
- data/lib/graphql/stitching/composer.rb +107 -112
- data/lib/graphql/stitching/executor/{boundary_source.rb → resolver_source.rb} +40 -32
- data/lib/graphql/stitching/executor.rb +3 -3
- data/lib/graphql/stitching/plan.rb +3 -4
- data/lib/graphql/stitching/planner.rb +30 -41
- data/lib/graphql/stitching/planner_step.rb +6 -6
- data/lib/graphql/stitching/resolver/arguments.rb +284 -0
- data/lib/graphql/stitching/resolver/keys.rb +206 -0
- data/lib/graphql/stitching/resolver.rb +70 -0
- data/lib/graphql/stitching/shaper.rb +3 -3
- data/lib/graphql/stitching/skip_include.rb +1 -1
- data/lib/graphql/stitching/supergraph/key_directive.rb +13 -0
- data/lib/graphql/stitching/supergraph/resolver_directive.rb +4 -4
- data/lib/graphql/stitching/supergraph/to_definition.rb +165 -0
- data/lib/graphql/stitching/supergraph.rb +31 -144
- data/lib/graphql/stitching/util.rb +28 -0
- data/lib/graphql/stitching/version.rb +1 -1
- data/lib/graphql/stitching.rb +3 -2
- metadata +11 -7
- data/lib/graphql/stitching/boundary.rb +0 -29
- data/lib/graphql/stitching/composer/validate_boundaries.rb +0 -96
- data/lib/graphql/stitching/export_selection.rb +0 -42
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 448ca61527e02c6f232fa811e7e99ba1aa243bc58685f313ca5613a6724ea306
|
4
|
+
data.tar.gz: a0af76ea7eb429d60f5a7e4df4c39103539a01a69136c769f027cef52a108cab
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8cea8a8674d67a421059e32778ffc3d1bbd699fa307efe2aead4571925ed77d8da7852431ef99c5809e0106ff16af212c26a6372c211b2d287d3a2c4cef5eb80
|
7
|
+
data.tar.gz: 972f74c60afef0c615d8dc6a3069bb7ddfbe88a84e22a8940d496037ab67f23491c639e8cc3e145b98697c921eb6c20577babec4be2a8e6a3221264e925cdc01
|
data/README.md
CHANGED
@@ -6,7 +6,7 @@ GraphQL stitching composes a single schema from multiple underlying GraphQL reso
|
|
6
6
|
|
7
7
|
**Supports:**
|
8
8
|
- Merged object and abstract types.
|
9
|
-
- Multiple keys per merged type.
|
9
|
+
- Multiple and composite keys per merged type.
|
10
10
|
- Shared objects, fields, enums, and inputs across locations.
|
11
11
|
- Combining local and remote schemas.
|
12
12
|
- File uploads via [multipart form spec](https://github.com/jaydenseric/graphql-multipart-request-spec).
|
@@ -94,7 +94,7 @@ To facilitate this merging of types, stitching must know how to cross-reference
|
|
94
94
|
Types merge through resolver queries identified by a `@stitch` directive:
|
95
95
|
|
96
96
|
```graphql
|
97
|
-
directive @stitch(key: String
|
97
|
+
directive @stitch(key: String!, arguments: String) repeatable on FIELD_DEFINITION
|
98
98
|
```
|
99
99
|
|
100
100
|
This directive (or [static configuration](#sdl-based-schemas)) is applied to root queries where a merged type may be accessed in each location, and a `key` argument specifies a field needed from other locations to be used as a query argument.
|
@@ -151,7 +151,7 @@ type Query {
|
|
151
151
|
```
|
152
152
|
|
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
|
-
* 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](#
|
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](#argument-shapes) later).
|
155
155
|
|
156
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
|
|
@@ -198,7 +198,7 @@ type Query {
|
|
198
198
|
To customize which types an abstract query provides and their respective keys, you may extend the `@stitch` directive with a `typeName` constraint. This can be repeated to select multiple types.
|
199
199
|
|
200
200
|
```graphql
|
201
|
-
directive @stitch(key: String!, typeName: String) repeatable on FIELD_DEFINITION
|
201
|
+
directive @stitch(key: String!, arguments: String, typeName: String) repeatable on FIELD_DEFINITION
|
202
202
|
|
203
203
|
type Product { sku: ID! }
|
204
204
|
type Order { id: ID! }
|
@@ -212,19 +212,69 @@ type Query {
|
|
212
212
|
}
|
213
213
|
```
|
214
214
|
|
215
|
-
####
|
215
|
+
#### Argument shapes
|
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
|
217
|
+
Stitching infers which argument to use for queries with a single argument, or when the key name matches its intended argument. For custom mappings, the `arguments` option may specify a template of GraphQL arguments that insert key selections:
|
218
218
|
|
219
219
|
```graphql
|
220
220
|
type Product {
|
221
221
|
id: ID!
|
222
222
|
}
|
223
223
|
type Query {
|
224
|
-
product(byId: ID, bySku: ID): Product
|
224
|
+
product(byId: ID, bySku: ID): Product
|
225
|
+
@stitch(key: "id", arguments: "byId: $.id")
|
225
226
|
}
|
226
227
|
```
|
227
228
|
|
229
|
+
Key insertions are prefixed by `$` and specify a dot-notation path to any selections made by the resolver `key`, or `__typename`. This syntax allows sending multiple arguments that intermix stitching keys with complex input shapes and other static values:
|
230
|
+
|
231
|
+
```graphql
|
232
|
+
type Product {
|
233
|
+
id: ID!
|
234
|
+
}
|
235
|
+
union Entity = Product
|
236
|
+
input EntityKey {
|
237
|
+
id: ID!
|
238
|
+
type: String!
|
239
|
+
}
|
240
|
+
|
241
|
+
type Query {
|
242
|
+
entities(keys: [EntityKey!]!, source: String="database"): [Entity]!
|
243
|
+
@stitch(key: "id", arguments: "keys: { id: $.id, type: $.__typename }, source: 'cache'")
|
244
|
+
}
|
245
|
+
```
|
246
|
+
|
247
|
+
See [resolver arguments](./docs/resolver.md#arguments) for full documentation on shaping input.
|
248
|
+
|
249
|
+
#### Composite type keys
|
250
|
+
|
251
|
+
Resolver keys may make composite selections for multiple key fields and/or nested scopes, for example:
|
252
|
+
|
253
|
+
```graphql
|
254
|
+
interface FieldOwner {
|
255
|
+
id: ID!
|
256
|
+
type: String!
|
257
|
+
}
|
258
|
+
type CustomField {
|
259
|
+
owner: FieldOwner!
|
260
|
+
key: String!
|
261
|
+
value: String
|
262
|
+
}
|
263
|
+
input CustomFieldLookup {
|
264
|
+
ownerId: ID!
|
265
|
+
ownerType: String!
|
266
|
+
key: String!
|
267
|
+
}
|
268
|
+
type Query {
|
269
|
+
customFields(lookups: [CustomFieldLookup!]!): [CustomField]! @stitch(
|
270
|
+
key: "owner { id type } key",
|
271
|
+
arguments: "lookups: { ownerId: $.owner.id, ownerType: $.owner.type, key: $.key }"
|
272
|
+
)
|
273
|
+
}
|
274
|
+
```
|
275
|
+
|
276
|
+
Note that composite key selections may _not_ be distributed across locations. The complete selection criteria must be available in each location that provides the key.
|
277
|
+
|
228
278
|
#### Multiple type keys
|
229
279
|
|
230
280
|
A type may exist in multiple locations across the graph using different keys, for example:
|
@@ -265,7 +315,7 @@ type Query {
|
|
265
315
|
The `@stitch` directive can be added to class-based schemas with a directive class:
|
266
316
|
|
267
317
|
```ruby
|
268
|
-
class
|
318
|
+
class StitchingResolver < GraphQL::Schema::Directive
|
269
319
|
graphql_name "stitch"
|
270
320
|
locations FIELD_DEFINITION
|
271
321
|
repeatable true
|
@@ -274,7 +324,7 @@ end
|
|
274
324
|
|
275
325
|
class Query < GraphQL::Schema::Object
|
276
326
|
field :product, Product, null: false do
|
277
|
-
directive
|
327
|
+
directive StitchingResolver, key: "id"
|
278
328
|
argument :id, ID, required: true
|
279
329
|
end
|
280
330
|
end
|
@@ -284,7 +334,7 @@ The `@stitch` directive can be exported from a class-based schema to an SDL stri
|
|
284
334
|
|
285
335
|
#### SDL-based schemas
|
286
336
|
|
287
|
-
A clean
|
337
|
+
A clean schema may also have stitching directives applied via static configuration by passing a `stitch` array in [location settings](./docs/composer.md#performing-composition):
|
288
338
|
|
289
339
|
```ruby
|
290
340
|
sdl_string = <<~GRAPHQL
|
@@ -316,7 +366,7 @@ supergraph = GraphQL::Stitching::Composer.new.perform({
|
|
316
366
|
The library is configured to use a `@stitch` directive by default. You may customize this by setting a new name during initialization:
|
317
367
|
|
318
368
|
```ruby
|
319
|
-
GraphQL::Stitching.stitch_directive = "
|
369
|
+
GraphQL::Stitching.stitch_directive = "resolver"
|
320
370
|
```
|
321
371
|
|
322
372
|
## Executables
|
@@ -365,17 +415,17 @@ The `GraphQL::Stitching::HttpExecutable` class is provided as a simple executabl
|
|
365
415
|
The stitching executor automatically batches subgraph requests so that only one request is made per location per generation of data. This is done using batched queries that combine all data access for a given a location. For example:
|
366
416
|
|
367
417
|
```graphql
|
368
|
-
query MyOperation_2 {
|
369
|
-
_0_result: widgets(ids:
|
370
|
-
_1_0_result: sprocket(id:
|
371
|
-
_1_1_result: sprocket(id:
|
372
|
-
_1_2_result: sprocket(id:
|
418
|
+
query MyOperation_2($_0_key:[ID!]!, $_1_0_key:ID!, $_1_1_key:ID!, $_1_2_key:ID!) {
|
419
|
+
_0_result: widgets(ids: $_0_key) { ... } # << 3 Widget
|
420
|
+
_1_0_result: sprocket(id: $_1_0_key) { ... } # << 1 Sprocket
|
421
|
+
_1_1_result: sprocket(id: $_1_1_key) { ... } # << 1 Sprocket
|
422
|
+
_1_2_result: sprocket(id: $_1_2_key) { ... } # << 1 Sprocket
|
373
423
|
}
|
374
424
|
```
|
375
425
|
|
376
426
|
Tips:
|
377
427
|
|
378
|
-
* List queries (like the `widgets` selection above) are
|
428
|
+
* List queries (like the `widgets` selection above) are generally preferable as resolver queries because they keep the batched document consistent regardless of set size, and make for smaller documents that parse and validate faster.
|
379
429
|
* Assure that root field resolvers across your subgraph implement batching to anticipate cases like the three `sprocket` selections above.
|
380
430
|
|
381
431
|
Otherwise, there's no developer intervention necessary (or generally possible) to improve upon data access. Note that multiple generations of data may still force the executor to return to a previous location for more data.
|
data/docs/README.md
CHANGED
@@ -14,4 +14,5 @@ Major components include:
|
|
14
14
|
|
15
15
|
Additional topics:
|
16
16
|
|
17
|
-
- [Stitching mechanics](./mechanics.md) -
|
17
|
+
- [Stitching mechanics](./mechanics.md) - more about building for stitching and how it operates.
|
18
|
+
- [Federation entities](./federation_entities.md) - more about Apollo Federation compatibility.
|
data/docs/mechanics.md
CHANGED
@@ -314,6 +314,7 @@ Merged types do not always require a resolver query. For example:
|
|
314
314
|
type Widget {
|
315
315
|
id: ID!
|
316
316
|
name: String
|
317
|
+
price: Float
|
317
318
|
}
|
318
319
|
|
319
320
|
type Query {
|
@@ -344,4 +345,4 @@ type Query {
|
|
344
345
|
}
|
345
346
|
```
|
346
347
|
|
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.
|
348
|
+
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 shared key field (such as `id` above) that allow them to join with data in other resolver locations (such as `price` above).
|
data/docs/resolver.md
ADDED
@@ -0,0 +1,101 @@
|
|
1
|
+
## GraphQL::Stitching::Resolver
|
2
|
+
|
3
|
+
A `Resolver` contains all information about a root query used by stitching to fetch location-specific variants of a merged type. Specifically, resolvers manage parsed keys and argument structures.
|
4
|
+
|
5
|
+
### Arguments
|
6
|
+
|
7
|
+
Resolvers configure arguments through a template string of [GraphQL argument literal syntax](https://spec.graphql.org/October2021/#sec-Language.Arguments). This allows sending multiple arguments that intermix stitching keys with complex object shapes and other static values.
|
8
|
+
|
9
|
+
#### Key insertions
|
10
|
+
|
11
|
+
Key values fetched from previous locations may be inserted into arguments. Key insertions are prefixed by `$` and specify a dot-notation path to any selections made by the resolver `key`, or `__typename`.
|
12
|
+
|
13
|
+
```graphql
|
14
|
+
type Query {
|
15
|
+
entity(id: ID!, type: String!): [Entity]!
|
16
|
+
@stitch(key: "owner { id }", arguments: "id: $.owner.id, type: $.__typename")
|
17
|
+
}
|
18
|
+
```
|
19
|
+
|
20
|
+
Key insertions are _not_ quoted to differentiate them from other literal values.
|
21
|
+
|
22
|
+
#### Lists
|
23
|
+
|
24
|
+
List arguments may specify input just like non-list arguments, and [GraphQL list input coercion](https://spec.graphql.org/October2021/#sec-List.Input-Coercion) will assume the shape represents a list item:
|
25
|
+
|
26
|
+
```graphql
|
27
|
+
type Query {
|
28
|
+
product(ids: [ID!]!, source: DataSource!): [Product]!
|
29
|
+
@stitch(key: "id", arguments: "ids: $.id, source: CACHE")
|
30
|
+
}
|
31
|
+
```
|
32
|
+
|
33
|
+
List resolvers (that return list types) may _only_ insert keys into repeatable list arguments, while non-list arguments may only contain static values. Nested list inputs are neither common nor practical, so are not supported.
|
34
|
+
|
35
|
+
#### Built-in scalars
|
36
|
+
|
37
|
+
Built-in scalars are written as normal literal values. For convenience, string literals may be enclosed in single quotes rather than escaped double-quotes:
|
38
|
+
|
39
|
+
```graphql
|
40
|
+
type Query {
|
41
|
+
product(id: ID!, source: String!): Product
|
42
|
+
@stitch(key: "id", arguments: "id: $.id, source: 'cache'")
|
43
|
+
|
44
|
+
variant(id: ID!, limit: Int!): Variant
|
45
|
+
@stitch(key: "id", arguments: "id: $.id, limit: 100")
|
46
|
+
}
|
47
|
+
```
|
48
|
+
|
49
|
+
All scalar usage must be legal to the resolver field's arguments schema.
|
50
|
+
|
51
|
+
#### Enums
|
52
|
+
|
53
|
+
Enum literals may be provided anywhere in the input structure. They are _not_ quoted:
|
54
|
+
|
55
|
+
```graphql
|
56
|
+
enum DataSource {
|
57
|
+
CACHE
|
58
|
+
}
|
59
|
+
type Query {
|
60
|
+
product(id: ID!, source: DataSource!): [Product]!
|
61
|
+
@stitch(key: "id", arguments: "id: $.id, source: CACHE")
|
62
|
+
}
|
63
|
+
```
|
64
|
+
|
65
|
+
All enum usage must be legal to the resolver field's arguments schema.
|
66
|
+
|
67
|
+
#### Input Objects
|
68
|
+
|
69
|
+
Input objects may be provided anywhere in the input, even as nested structures. The stitching resolver will build the specified object shape:
|
70
|
+
|
71
|
+
```graphql
|
72
|
+
input ComplexKey {
|
73
|
+
id: ID
|
74
|
+
nested: ComplexKey
|
75
|
+
}
|
76
|
+
type Query {
|
77
|
+
product(key: ComplexKey!): [Product]!
|
78
|
+
@stitch(key: "id", arguments: "key: { nested: { id: $.id } }")
|
79
|
+
}
|
80
|
+
```
|
81
|
+
|
82
|
+
Input object shapes must conform to their respective schema definitions based on their placement within resolver arguments.
|
83
|
+
|
84
|
+
#### Custom scalars
|
85
|
+
|
86
|
+
Custom scalar keys allow any input shape to be submitted, from primitive scalars to complex object structures. These values will be sent and recieved as untyped JSON input:
|
87
|
+
|
88
|
+
```graphql
|
89
|
+
type Product {
|
90
|
+
id: ID!
|
91
|
+
}
|
92
|
+
union Entity = Product
|
93
|
+
scalar Key
|
94
|
+
|
95
|
+
type Query {
|
96
|
+
entities(representations: [Key!]!): [Entity]!
|
97
|
+
@stitch(key: "id", arguments: "representations: { id: $.id, __typename: $.__typename }")
|
98
|
+
}
|
99
|
+
```
|
100
|
+
|
101
|
+
Custom scalar arguments have no structured schema definition to validate against. This makes them flexible but quite lax, for better or worse.
|
@@ -70,7 +70,11 @@ module GraphQL
|
|
70
70
|
def load_plan(request)
|
71
71
|
if @on_cache_read && plan_json = @on_cache_read.call(request)
|
72
72
|
plan = GraphQL::Stitching::Plan.from_json(JSON.parse(plan_json))
|
73
|
-
|
73
|
+
|
74
|
+
# only use plans referencing current resolver versions
|
75
|
+
if plan.ops.all? { |op| !op.resolver || @supergraph.resolvers_by_version[op.resolver] }
|
76
|
+
return request.plan(plan)
|
77
|
+
end
|
74
78
|
end
|
75
79
|
|
76
80
|
plan = request.plan
|
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
module GraphQL::Stitching
|
4
4
|
class Composer
|
5
|
-
class
|
5
|
+
class ResolverConfig
|
6
6
|
ENTITY_TYPENAME = "_Entity"
|
7
7
|
ENTITIES_QUERY = "_entities"
|
8
8
|
|
@@ -12,10 +12,10 @@ module GraphQL::Stitching
|
|
12
12
|
|
13
13
|
assignments.each_with_object({}) do |kwargs, memo|
|
14
14
|
type = kwargs[:parent_type_name] ? schema.get_type(kwargs[:parent_type_name]) : schema.query
|
15
|
-
raise
|
15
|
+
raise CompositionError, "Invalid stitch directive type `#{kwargs[:parent_type_name]}`" unless type
|
16
16
|
|
17
17
|
field = type.get_field(kwargs[:field_name])
|
18
|
-
raise
|
18
|
+
raise CompositionError, "Invalid stitch directive field `#{kwargs[:field_name]}`" unless field
|
19
19
|
|
20
20
|
field_path = "#{location}.#{field.name}"
|
21
21
|
memo[field_path] ||= []
|
@@ -30,15 +30,15 @@ module GraphQL::Stitching
|
|
30
30
|
entity_type.directives.each do |directive|
|
31
31
|
next unless directive.graphql_name == "key"
|
32
32
|
|
33
|
-
key = directive.arguments.keyword_arguments.fetch(:fields)
|
34
|
-
|
35
|
-
|
33
|
+
key = Resolver.parse_key(directive.arguments.keyword_arguments.fetch(:fields))
|
34
|
+
key_fields = key.map { "#{_1.name}: $.#{_1.name}" }
|
36
35
|
field_path = "#{location}._entities"
|
36
|
+
|
37
37
|
memo[field_path] ||= []
|
38
38
|
memo[field_path] << new(
|
39
|
-
key: key,
|
39
|
+
key: key.to_definition,
|
40
40
|
type_name: entity_type.graphql_name,
|
41
|
-
|
41
|
+
arguments: "representations: { #{key_fields.join(", ")}, __typename: $.__typename }",
|
42
42
|
)
|
43
43
|
end
|
44
44
|
end
|
@@ -48,7 +48,7 @@ module GraphQL::Stitching
|
|
48
48
|
new(
|
49
49
|
key: kwargs[:key],
|
50
50
|
type_name: kwargs[:type_name] || kwargs[:typeName],
|
51
|
-
|
51
|
+
arguments: kwargs[:arguments],
|
52
52
|
)
|
53
53
|
end
|
54
54
|
|
@@ -57,16 +57,21 @@ module GraphQL::Stitching
|
|
57
57
|
def federation_entities_schema?(schema)
|
58
58
|
entity_type = schema.get_type(ENTITY_TYPENAME)
|
59
59
|
entities_query = schema.query.get_field(ENTITIES_QUERY)
|
60
|
-
entity_type &&
|
60
|
+
entity_type &&
|
61
|
+
entity_type.kind.union? &&
|
62
|
+
entities_query &&
|
63
|
+
entities_query.arguments["representations"] &&
|
64
|
+
entities_query.type.list? &&
|
65
|
+
entities_query.type.unwrap == entity_type
|
61
66
|
end
|
62
67
|
end
|
63
68
|
|
64
|
-
attr_reader :key, :type_name, :
|
69
|
+
attr_reader :key, :type_name, :arguments
|
65
70
|
|
66
|
-
def initialize(key:, type_name:,
|
71
|
+
def initialize(key:, type_name:, arguments: nil)
|
67
72
|
@key = key
|
68
73
|
@type_name = type_name
|
69
|
-
@
|
74
|
+
@arguments = arguments
|
70
75
|
end
|
71
76
|
end
|
72
77
|
end
|
@@ -15,7 +15,7 @@ module GraphQL::Stitching
|
|
15
15
|
# graphql-ruby will dynamically apply interface fields on a type implementation,
|
16
16
|
# so check the delegation map to assure that all materialized fields have resolver locations.
|
17
17
|
unless supergraph.locations_by_type_and_field[possible_type.graphql_name][field_name]&.any?
|
18
|
-
raise
|
18
|
+
raise ValidationError, "Type #{possible_type.graphql_name} does not implement a `#{field_name}` field in any location, "\
|
19
19
|
"which is required by interface #{interface_type.graphql_name}."
|
20
20
|
end
|
21
21
|
|
@@ -24,7 +24,7 @@ module GraphQL::Stitching
|
|
24
24
|
possible_type_structure = Util.flatten_type_structure(intersecting_field.type)
|
25
25
|
|
26
26
|
if possible_type_structure.length != interface_type_structure.length
|
27
|
-
raise
|
27
|
+
raise ValidationError, "Incompatible list structures between field #{possible_type.graphql_name}.#{field_name} of type "\
|
28
28
|
"#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
|
29
29
|
end
|
30
30
|
|
@@ -32,12 +32,12 @@ module GraphQL::Stitching
|
|
32
32
|
possible_struct = possible_type_structure[index]
|
33
33
|
|
34
34
|
if possible_struct.name != interface_struct.name
|
35
|
-
raise
|
35
|
+
raise ValidationError, "Incompatible named types between field #{possible_type.graphql_name}.#{field_name} of type "\
|
36
36
|
"#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
|
37
37
|
end
|
38
38
|
|
39
39
|
if possible_struct.null? && interface_struct.non_null?
|
40
|
-
raise
|
40
|
+
raise ValidationError, "Incompatible nullability between field #{possible_type.graphql_name}.#{field_name} of type "\
|
41
41
|
"#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
|
42
42
|
end
|
43
43
|
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GraphQL::Stitching
|
4
|
+
class Composer
|
5
|
+
class ValidateResolvers < BaseValidator
|
6
|
+
|
7
|
+
def perform(supergraph, composer)
|
8
|
+
supergraph.schema.types.each do |type_name, type|
|
9
|
+
# objects and interfaces that are not the root operation types
|
10
|
+
next unless type.kind.object? || type.kind.interface?
|
11
|
+
next if supergraph.schema.query == type || supergraph.schema.mutation == type
|
12
|
+
next if type.graphql_name.start_with?("__")
|
13
|
+
|
14
|
+
# multiple subschemas implement the type
|
15
|
+
subgraph_types_by_location = composer.subgraph_types_by_name_and_location[type_name]
|
16
|
+
next unless subgraph_types_by_location.length > 1
|
17
|
+
|
18
|
+
resolvers = supergraph.resolvers[type_name]
|
19
|
+
if resolvers&.any?
|
20
|
+
validate_as_resolver(supergraph, type, subgraph_types_by_location, resolvers)
|
21
|
+
elsif type.kind.object?
|
22
|
+
validate_as_shared(supergraph, type, subgraph_types_by_location)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def validate_as_resolver(supergraph, type, subgraph_types_by_location, resolvers)
|
30
|
+
# abstract resolvers are expanded with their concrete implementations, which each get validated. Ignore the abstract itself.
|
31
|
+
return if type.kind.abstract?
|
32
|
+
|
33
|
+
# only one resolver allowed per type/location/key
|
34
|
+
resolvers_by_location_and_key = resolvers.each_with_object({}) do |resolver, memo|
|
35
|
+
if memo.dig(resolver.location, resolver.key.to_definition)
|
36
|
+
raise ValidationError, "Multiple resolver queries for `#{type.graphql_name}.#{resolver.key}` "\
|
37
|
+
"found in #{resolver.location}. Limit one resolver query per type and key in each location. "\
|
38
|
+
"Abstract resolvers provide all possible types."
|
39
|
+
end
|
40
|
+
memo[resolver.location] ||= {}
|
41
|
+
memo[resolver.location][resolver.key.to_definition] = resolver
|
42
|
+
end
|
43
|
+
|
44
|
+
resolver_keys = resolvers.map(&:key)
|
45
|
+
resolver_key_strs = resolver_keys.map(&:to_definition).to_set
|
46
|
+
|
47
|
+
# All non-key fields must be resolvable in at least one resolver location
|
48
|
+
supergraph.locations_by_type_and_field[type.graphql_name].each do |field_name, locations|
|
49
|
+
next if resolver_key_strs.include?(field_name)
|
50
|
+
|
51
|
+
if locations.none? { resolvers_by_location_and_key[_1] }
|
52
|
+
where = locations.length > 1 ? "one of #{locations.join(", ")} locations" : locations.first
|
53
|
+
raise ValidationError, "A resolver query is required for `#{type.graphql_name}` in #{where} to resolve field `#{field_name}`."
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# All locations of a merged type must include at least one resolver key
|
58
|
+
supergraph.fields_by_type_and_location[type.graphql_name].each_key do |location|
|
59
|
+
if resolver_keys.none? { _1.locations.include?(location) }
|
60
|
+
raise ValidationError, "A resolver key is required for `#{type.graphql_name}` in #{location} to join with other locations."
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# verify that all outbound locations can access all inbound locations
|
65
|
+
resolver_locations = resolvers_by_location_and_key.keys
|
66
|
+
subgraph_types_by_location.each_key do |location|
|
67
|
+
remote_locations = resolver_locations.reject { _1 == location }
|
68
|
+
paths = supergraph.route_type_to_locations(type.graphql_name, location, remote_locations)
|
69
|
+
if paths.length != remote_locations.length || paths.any? { |_loc, path| path.nil? }
|
70
|
+
raise ValidationError, "Cannot route `#{type.graphql_name}` resolvers in #{location} to all other locations. "\
|
71
|
+
"All locations must provide a resolver query with a joining key."
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def validate_as_shared(supergraph, type, subgraph_types_by_location)
|
77
|
+
expected_fields = begin
|
78
|
+
type.fields.keys.sort
|
79
|
+
rescue StandardError => e
|
80
|
+
# bug with inherited interfaces in older versions of GraphQL
|
81
|
+
if type.interfaces.any? { _1.is_a?(GraphQL::Schema::LateBoundType) }
|
82
|
+
raise CompositionError, "Merged interface inheritance requires GraphQL >= v2.0.3"
|
83
|
+
else
|
84
|
+
raise e
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
subgraph_types_by_location.each do |location, subgraph_type|
|
89
|
+
if subgraph_type.fields.keys.sort != expected_fields
|
90
|
+
raise ValidationError, "Shared type `#{type.graphql_name}` must have consistent fields across locations, "\
|
91
|
+
"or else define resolver queries so that its unique fields may be accessed remotely."
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|