graphql-stitching 1.3.0 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d33ec469a7f4598bd9f198b25102bcb3453d034b124fb18bcfd8ec595b78a6ce
4
- data.tar.gz: a6ef6c8e218045a508c22ade74fb83504d762c0a6cadb5ed3cd4f58a89bddcea
3
+ metadata.gz: 448ca61527e02c6f232fa811e7e99ba1aa243bc58685f313ca5613a6724ea306
4
+ data.tar.gz: a0af76ea7eb429d60f5a7e4df4c39103539a01a69136c769f027cef52a108cab
5
5
  SHA512:
6
- metadata.gz: dc753d25c70045d1f2f995a0bbc8329ab426e1c5426943fa88e03fa71128fe8c1da928a8c905c997396c4d248e26e4400a02e2b2bc56d9e26b4916b5c63a8a7b
7
- data.tar.gz: fdea7099a1cb486df512bdb93e7d90cceef42e7ff875e4c72c8e4b1fb46836d9e15cf5ae6124ca81132ec2a0707aca08a0d2a80d35fc40b18cf036c12576ff43
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:
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
@@ -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
- representations: 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
- representations: kwargs[:representations] || 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, :representations
69
+ attr_reader :key, :type_name, :arguments
65
70
 
66
- def initialize(key:, type_name:, representations: false)
71
+ def initialize(key:, type_name:, arguments: nil)
67
72
  @key = key
68
73
  @type_name = type_name
69
- @representations = representations
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
@@ -12,81 +12,82 @@ module GraphQL::Stitching
12
12
  next if type.graphql_name.start_with?("__")
13
13
 
14
14
  # multiple subschemas implement the type
15
- candidate_types_by_location = composer.candidate_types_by_name_and_location[type_name]
16
- next unless candidate_types_by_location.length > 1
15
+ subgraph_types_by_location = composer.subgraph_types_by_name_and_location[type_name]
16
+ next unless subgraph_types_by_location.length > 1
17
17
 
18
18
  resolvers = supergraph.resolvers[type_name]
19
19
  if resolvers&.any?
20
- validate_as_resolver(supergraph, type, candidate_types_by_location, resolvers)
20
+ validate_as_resolver(supergraph, type, subgraph_types_by_location, resolvers)
21
21
  elsif type.kind.object?
22
- validate_as_shared(supergraph, type, candidate_types_by_location)
22
+ validate_as_shared(supergraph, type, subgraph_types_by_location)
23
23
  end
24
24
  end
25
25
  end
26
26
 
27
27
  private
28
28
 
29
- def validate_as_resolver(supergraph, type, candidate_types_by_location, resolvers)
29
+ def validate_as_resolver(supergraph, type, subgraph_types_by_location, resolvers)
30
30
  # abstract resolvers 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 resolver allowed per type/location/key
34
34
  resolvers_by_location_and_key = resolvers.each_with_object({}) do |resolver, memo|
35
- if memo.dig(resolver.location, resolver.key)
36
- raise Composer::ValidationError, "Multiple resolver queries for `#{type.graphql_name}.#{resolver.key}` "\
35
+ if memo.dig(resolver.location, resolver.key.to_definition)
36
+ raise ValidationError, "Multiple resolver queries for `#{type.graphql_name}.#{resolver.key}` "\
37
37
  "found in #{resolver.location}. Limit one resolver query per type and key in each location. "\
38
38
  "Abstract resolvers provide all possible types."
39
39
  end
40
40
  memo[resolver.location] ||= {}
41
- memo[resolver.location][resolver.key] = resolver
41
+ memo[resolver.location][resolver.key.to_definition] = resolver
42
42
  end
43
43
 
44
- resolver_keys = resolvers.map(&:key).to_set
44
+ resolver_keys = resolvers.map(&:key)
45
+ resolver_key_strs = resolver_keys.map(&:to_definition).to_set
45
46
 
46
47
  # All non-key fields must be resolvable in at least one resolver location
47
48
  supergraph.locations_by_type_and_field[type.graphql_name].each do |field_name, locations|
48
- next if resolver_keys.include?(field_name)
49
+ next if resolver_key_strs.include?(field_name)
49
50
 
50
51
  if locations.none? { resolvers_by_location_and_key[_1] }
51
52
  where = locations.length > 1 ? "one of #{locations.join(", ")} locations" : locations.first
52
- raise Composer::ValidationError, "A resolver query is required for `#{type.graphql_name}` in #{where} to resolve field `#{field_name}`."
53
+ raise ValidationError, "A resolver query is required for `#{type.graphql_name}` in #{where} to resolve field `#{field_name}`."
53
54
  end
54
55
  end
55
56
 
56
- # All locations of a resolver 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? { resolver_keys.include?(_1) }
59
- raise Composer::ValidationError, "A resolver key is required for `#{type.graphql_name}` in #{location} to join with other locations."
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."
60
61
  end
61
62
  end
62
63
 
63
64
  # verify that all outbound locations can access all inbound locations
64
65
  resolver_locations = resolvers_by_location_and_key.keys
65
- candidate_types_by_location.each_key do |location|
66
+ subgraph_types_by_location.each_key do |location|
66
67
  remote_locations = resolver_locations.reject { _1 == location }
67
68
  paths = supergraph.route_type_to_locations(type.graphql_name, location, remote_locations)
68
69
  if paths.length != remote_locations.length || paths.any? { |_loc, path| path.nil? }
69
- raise Composer::ValidationError, "Cannot route `#{type.graphql_name}` resolvers in #{location} to all other locations. "\
70
+ raise ValidationError, "Cannot route `#{type.graphql_name}` resolvers in #{location} to all other locations. "\
70
71
  "All locations must provide a resolver query with a joining key."
71
72
  end
72
73
  end
73
74
  end
74
75
 
75
- def validate_as_shared(supergraph, type, candidate_types_by_location)
76
+ def validate_as_shared(supergraph, type, subgraph_types_by_location)
76
77
  expected_fields = begin
77
78
  type.fields.keys.sort
78
79
  rescue StandardError => e
79
80
  # bug with inherited interfaces in older versions of GraphQL
80
81
  if type.interfaces.any? { _1.is_a?(GraphQL::Schema::LateBoundType) }
81
- raise Composer::ComposerError, "Merged interface inheritance requires GraphQL >= v2.0.3"
82
+ raise CompositionError, "Merged interface inheritance requires GraphQL >= v2.0.3"
82
83
  else
83
84
  raise e
84
85
  end
85
86
  end
86
87
 
87
- candidate_types_by_location.each do |location, candidate_type|
88
- if candidate_type.fields.keys.sort != expected_fields
89
- raise Composer::ValidationError, "Shared type `#{type.graphql_name}` must have consistent fields across locations, "\
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, "\
90
91
  "or else define resolver queries so that its unique fields may be accessed remotely."
91
92
  end
92
93
  end