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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +67 -17
  3. data/docs/README.md +2 -1
  4. data/docs/mechanics.md +2 -1
  5. data/docs/resolver.md +101 -0
  6. data/lib/graphql/stitching/client.rb +5 -1
  7. data/lib/graphql/stitching/composer/{boundary_config.rb → resolver_config.rb} +18 -13
  8. data/lib/graphql/stitching/composer/validate_interfaces.rb +4 -4
  9. data/lib/graphql/stitching/composer/validate_resolvers.rb +97 -0
  10. data/lib/graphql/stitching/composer.rb +107 -112
  11. data/lib/graphql/stitching/executor/{boundary_source.rb → resolver_source.rb} +40 -32
  12. data/lib/graphql/stitching/executor.rb +3 -3
  13. data/lib/graphql/stitching/plan.rb +3 -4
  14. data/lib/graphql/stitching/planner.rb +30 -41
  15. data/lib/graphql/stitching/planner_step.rb +6 -6
  16. data/lib/graphql/stitching/resolver/arguments.rb +284 -0
  17. data/lib/graphql/stitching/resolver/keys.rb +206 -0
  18. data/lib/graphql/stitching/resolver.rb +70 -0
  19. data/lib/graphql/stitching/shaper.rb +3 -3
  20. data/lib/graphql/stitching/skip_include.rb +1 -1
  21. data/lib/graphql/stitching/supergraph/key_directive.rb +13 -0
  22. data/lib/graphql/stitching/supergraph/resolver_directive.rb +4 -4
  23. data/lib/graphql/stitching/supergraph/to_definition.rb +165 -0
  24. data/lib/graphql/stitching/supergraph.rb +31 -144
  25. data/lib/graphql/stitching/util.rb +28 -0
  26. data/lib/graphql/stitching/version.rb +1 -1
  27. data/lib/graphql/stitching.rb +3 -2
  28. metadata +11 -7
  29. data/lib/graphql/stitching/boundary.rb +0 -29
  30. data/lib/graphql/stitching/composer/validate_boundaries.rb +0 -96
  31. data/lib/graphql/stitching/export_selection.rb +0 -42
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 754e7a61df541f685ef50ec79039df9742e4a2589a30eadf93125d6e4ef47aed
4
- data.tar.gz: e2ceb89be07b42dbffeaa75416055fa62ffafa001088fd47b2b4f156aee6123a
3
+ metadata.gz: 448ca61527e02c6f232fa811e7e99ba1aa243bc58685f313ca5613a6724ea306
4
+ data.tar.gz: a0af76ea7eb429d60f5a7e4df4c39103539a01a69136c769f027cef52a108cab
5
5
  SHA512:
6
- metadata.gz: 561ecdc36e78b31e4f4fd2f85d4ff5e0f1ab23d44570de7d8ffe63c2e46b5856214eb07c25b98eafbd64857602ebd839e187175e9e3ec347caedf4651a00d332
7
- data.tar.gz: dd7a98e1c97f7bd5a6ca2585c34b6538e94b6dfaaa83edf9b3f1bc287a79d04f62a29ebe2a6b6fae4d06e8af9f08bb20203a89f1b062f24dc622a66973f87abc
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!) repeatable on FIELD_DEFINITION
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](#multiple-query-arguments) later).
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
- #### Multiple query arguments
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 queries that accept multiple arguments with unmatched names, the key should provide an argument alias specified as `"<arg>:<key>"`.
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 @stitch(key: "byId:id")
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 StitchField < GraphQL::Schema::Directive
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 StitchField, key: "id"
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 SDL string may also have stitching directives applied via static configuration by passing a `stitch` array in [location settings](./docs/composer.md#performing-composition):
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 = "merge"
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:["a","b","c"]) { ... } # << 3 Widget
370
- _1_0_result: sprocket(id:"x") { ... } # << 1 Sprocket
371
- _1_1_result: sprocket(id:"y") { ... } # << 1 Sprocket
372
- _1_2_result: sprocket(id:"z") { ... } # << 1 Sprocket
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 more compact for accessing multiple records, and are therefore preferable as stitching accessors.
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) - learn more about building for stitching.
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
- return request.plan(plan)
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 BoundaryConfig
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 ComposerError, "Invalid stitch directive type `#{kwargs[:parent_type_name]}`" unless type
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 ComposerError, "Invalid stitch directive field `#{kwargs[:field_name]}`" unless field
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).strip
34
- raise ComposerError, "Composite federation keys are not supported." unless /^\w+$/.match?(key)
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
- federation: true,
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
- federation: kwargs[:federation] || false,
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 && entity_type.kind.union? && entities_query && entities_query.type.unwrap == 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, :federation
69
+ attr_reader :key, :type_name, :arguments
65
70
 
66
- def initialize(key:, type_name:, federation: false)
71
+ def initialize(key:, type_name:, arguments: nil)
67
72
  @key = key
68
73
  @type_name = type_name
69
- @federation = federation
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 Composer::ValidationError, "Type #{possible_type.graphql_name} does not implement a `#{field_name}` field in any location, "\
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 Composer::ValidationError, "Incompatible list structures between field #{possible_type.graphql_name}.#{field_name} of type "\
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 Composer::ValidationError, "Incompatible named types between field #{possible_type.graphql_name}.#{field_name} of type "\
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 Composer::ValidationError, "Incompatible nullability between field #{possible_type.graphql_name}.#{field_name} of type "\
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