graphql-stitching 1.3.0 → 1.4.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d33ec469a7f4598bd9f198b25102bcb3453d034b124fb18bcfd8ec595b78a6ce
4
- data.tar.gz: a6ef6c8e218045a508c22ade74fb83504d762c0a6cadb5ed3cd4f58a89bddcea
3
+ metadata.gz: b7c50a82a3fa6298b224bef6923478217e82e6ddd4b6b09ca33df72f690edbdc
4
+ data.tar.gz: 545e2817523bbcedcb84b2512942eb92ae66e5353c46d4556eb892590fa868b3
5
5
  SHA512:
6
- metadata.gz: dc753d25c70045d1f2f995a0bbc8329ab426e1c5426943fa88e03fa71128fe8c1da928a8c905c997396c4d248e26e4400a02e2b2bc56d9e26b4916b5c63a8a7b
7
- data.tar.gz: fdea7099a1cb486df512bdb93e7d90cceef42e7ff875e4c72c8e4b1fb46836d9e15cf5ae6124ca81132ec2a0707aca08a0d2a80d35fc40b18cf036c12576ff43
6
+ metadata.gz: 426d87b634cb77ad8bb185c8a23fbbc1439abbc60b55f6561999eb64d9c08642496be4eafd3415ccd47aebef21b39f349aca38be7c6ada4d34f763e7910a3b67
7
+ data.tar.gz: 50711d59f93fc17809c2953a014438a3f59ea952030c0112393acacd8066d90e9824b332dfda9546d3bdc74e7a030442e5b74f3b2fdda61df356d7832469020b
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