graphql-stitching 1.2.3 → 1.2.4

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: 99acc247a0e5e227236b0261b3f975e7277f642a9d76daaf4f6a985bfc4825ea
4
- data.tar.gz: ef5f01132498ca4ba1be6f8593f189645ddf8fb6b6d8ab71f9b7d0e55d6ec682
3
+ metadata.gz: 5c1c6ae95fd6ec036a7678356c8bc48191d8cf5cfd9e4b2936e79fdb72b52f48
4
+ data.tar.gz: 7f8002d013e26f69df5a7023e147f98b86a46cc3027d2753b6d704bfe75a835b
5
5
  SHA512:
6
- metadata.gz: d1bd0db4c7f6d363231330222006f2bbc552a669227a38aeb7861dde843b59bc3e61b0f301f97d4a5c6b55193d01424cd9f240c0ff7a7ee483e49150686ac3da
7
- data.tar.gz: 348aa663608fd8b0e5544ccf7ec500d4295a63bb9bd19a91031f927165da2a206024ce6cb17111c3ebda2c172e08072be8d01716c11f0a05c3ea8a9a4b99466e
6
+ metadata.gz: 734fe2a73f5412f30ccc6d89a20e62c1da376422de65a816a9680f02330cb2df93c39803cc182c5eed1c310624509beccb0a69162c1dad407ccaf6ada69178dd
7
+ data.tar.gz: c293a3de403d0888d15047e2de120a8187e4fee598229572f37905dd4e4b3429017ee958ae8ab2a2c16561d25761128b9a6ffb954cbf49b5a310c92aa77f7ffe
data/README.md CHANGED
@@ -10,12 +10,13 @@ GraphQL stitching composes a single schema from multiple underlying GraphQL reso
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).
13
+ - Tested with all minor versions of `graphql-ruby`.
13
14
 
14
15
  **NOT Supported:**
15
16
  - Computed fields (ie: federation-style `@requires`).
16
17
  - Subscriptions, defer/stream.
17
18
 
18
- This Ruby implementation is a sibling to [GraphQL Tools](https://the-guild.dev/graphql/stitching) (JS) and [Bramble](https://movio.github.io/bramble/) (Go), and its capabilities fall somewhere in between them. GraphQL stitching is similar in concept to [Apollo Federation](https://www.apollographql.com/docs/federation/), though more generic. The opportunity here is for a Ruby application to stitch its local schemas together or onto remote sources without requiring an additional proxy service running in another language. If your goal is to build a purely high-throughput API gateway, consider not using Ruby.
19
+ This Ruby implementation is a sibling to [GraphQL Tools](https://the-guild.dev/graphql/stitching) (JS) and [Bramble](https://movio.github.io/bramble/) (Go), and its capabilities fall somewhere in between them. GraphQL stitching is similar in concept to [Apollo Federation](https://www.apollographql.com/docs/federation/), though more generic. The opportunity here is for a Ruby application to stitch its local schemas together or onto remote sources without requiring an additional proxy service running in another language. If your goal is to build a purely high-performance API gateway, consider not using Ruby.
19
20
 
20
21
  ## Getting started
21
22
 
@@ -179,7 +180,7 @@ See [error handling](./docs/mechanics.md#stitched-errors) tips for list queries.
179
180
 
180
181
  #### Abstract queries
181
182
 
182
- It's okay for stitching queries to be implemented through abstract types. An abstract query will provide access to all of its possible types. For interfaces, the key selection should match a field within the interface. For unions, all possible types must implement the key selection individually.
183
+ It's okay for stitching queries to be implemented through abstract types. An abstract query will provide access to all of its possible types by default, each of which must implement the key.
183
184
 
184
185
  ```graphql
185
186
  interface Node {
@@ -194,13 +195,33 @@ type Query {
194
195
  }
195
196
  ```
196
197
 
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
+
200
+ ```graphql
201
+ directive @stitch(key: String!, typeName: String) repeatable on FIELD_DEFINITION
202
+
203
+ type Product { sku: ID! }
204
+ type Order { id: ID! }
205
+ type Customer { id: ID! } # << not stitched
206
+ union Entity = Product | Order | Customer
207
+
208
+ type Query {
209
+ entity(key: ID!): Entity
210
+ @stitch(key: "sku", typeName: "Product")
211
+ @stitch(key: "id", typeName: "Order")
212
+ }
213
+ ```
214
+
197
215
  #### Multiple query arguments
198
216
 
199
- Stitching infers which argument to use for queries with a single argument. For queries that accept multiple arguments, the key must provide an argument mapping specified as `"<arg>:<key>"`. Note the `"id:id"` key:
217
+ Stitching infers which argument to use for queries with a single argument, or when the key name matches its intended argument. For queries that accept multiple arguments with unmatched names, the key may provide an argument mapping specified as `"<arg>:<key>"`.
200
218
 
201
219
  ```graphql
220
+ type Product {
221
+ id: ID!
222
+ }
202
223
  type Query {
203
- product(id: ID, upc: ID): Product @stitch(key: "id:id")
224
+ product(by_id: ID, by_sku: ID): Product @stitch(key: "by_id:id")
204
225
  }
205
226
  ```
206
227
 
@@ -210,8 +231,8 @@ A type may exist in multiple locations across the graph using different keys, fo
210
231
 
211
232
  ```graphql
212
233
  type Product { id:ID! } # storefronts location
213
- type Product { id:ID! upc:ID! } # products location
214
- type Product { upc:ID! } # catelog location
234
+ type Product { id:ID! sku:ID! } # products location
235
+ type Product { sku:ID! } # catelog location
215
236
  ```
216
237
 
217
238
  In the above graph, the `storefronts` and `catelog` locations have different keys that join through an intermediary. This pattern is perfectly valid and resolvable as long as the intermediary provides stitching queries for each possible key:
@@ -219,11 +240,11 @@ In the above graph, the `storefronts` and `catelog` locations have different key
219
240
  ```graphql
220
241
  type Product {
221
242
  id: ID!
222
- upc: ID!
243
+ sku: ID!
223
244
  }
224
245
  type Query {
225
246
  productById(id: ID!): Product @stitch(key: "id")
226
- productByUpc(upc: ID!): Product @stitch(key: "upc")
247
+ productByUpc(sku: ID!): Product @stitch(key: "sku")
227
248
  }
228
249
  ```
229
250
 
@@ -232,10 +253,10 @@ The `@stitch` directive is also repeatable, allowing a single query to associate
232
253
  ```graphql
233
254
  type Product {
234
255
  id: ID!
235
- upc: ID!
256
+ sku: ID!
236
257
  }
237
258
  type Query {
238
- product(id: ID, upc: ID): Product @stitch(key: "id:id") @stitch(key: "upc:upc")
259
+ product(id: ID, sku: ID): Product @stitch(key: "id") @stitch(key: "sku")
239
260
  }
240
261
  ```
241
262
 
@@ -269,11 +290,11 @@ A clean SDL string may also have stitching directives applied via static configu
269
290
  sdl_string = <<~GRAPHQL
270
291
  type Product {
271
292
  id: ID!
272
- upc: ID!
293
+ sku: ID!
273
294
  }
274
295
  type Query {
275
296
  productById(id: ID!): Product
276
- productByUpc(upc: ID!): Product
297
+ productByUpc(sku: ID!): Product
277
298
  }
278
299
  GRAPHQL
279
300
 
@@ -283,7 +304,7 @@ supergraph = GraphQL::Stitching::Composer.new.perform({
283
304
  executable: ->() { ... },
284
305
  stitch: [
285
306
  { field_name: "productById", key: "id" },
286
- { field_name: "productByUpc", key: "upc" },
307
+ { field_name: "productByUpc", key: "sku" },
287
308
  ]
288
309
  },
289
310
  # ...
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL::Stitching
4
+ class Composer
5
+ class BoundaryConfig
6
+ ENTITY_TYPENAME = "_Entity"
7
+ ENTITIES_QUERY = "_entities"
8
+
9
+ class << self
10
+ def extract_directive_assignments(schema, location, assignments)
11
+ return EMPTY_OBJECT unless assignments && assignments.any?
12
+
13
+ assignments.each_with_object({}) do |kwargs, memo|
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
16
+
17
+ field = type.get_field(kwargs[:field_name])
18
+ raise ComposerError, "Invalid stitch directive field `#{kwargs[:field_name]}`" unless field
19
+
20
+ field_path = "#{location}.#{field.name}"
21
+ memo[field_path] ||= []
22
+ memo[field_path] << from_kwargs(kwargs)
23
+ end
24
+ end
25
+
26
+ def extract_federation_entities(schema, location)
27
+ return EMPTY_OBJECT unless federation_entities_schema?(schema)
28
+
29
+ schema.possible_types(schema.get_type(ENTITY_TYPENAME)).each_with_object({}) do |entity_type, memo|
30
+ entity_type.directives.each do |directive|
31
+ next unless directive.graphql_name == "key"
32
+
33
+ key = directive.arguments.keyword_arguments.fetch(:fields).strip
34
+ raise ComposerError, "Composite federation keys are not supported." unless /^\w+$/.match?(key)
35
+
36
+ field_path = "#{location}._entities"
37
+ memo[field_path] ||= []
38
+ memo[field_path] << new(
39
+ key: key,
40
+ type_name: entity_type.graphql_name,
41
+ federation: true,
42
+ )
43
+ end
44
+ end
45
+ end
46
+
47
+ def from_kwargs(kwargs)
48
+ new(
49
+ key: kwargs[:key],
50
+ type_name: kwargs[:type_name] || kwargs[:typeName],
51
+ federation: kwargs[:federation] || false,
52
+ )
53
+ end
54
+
55
+ private
56
+
57
+ def federation_entities_schema?(schema)
58
+ entity_type = schema.get_type(ENTITY_TYPENAME)
59
+ entities_query = schema.query.get_field(ENTITIES_QUERY)
60
+ entity_type && entity_type.kind.union? && entities_query && entities_query.type.unwrap == entity_type
61
+ end
62
+ end
63
+
64
+ attr_reader :key, :type_name, :federation
65
+
66
+ def initialize(key:, type_name:, federation: false)
67
+ @key = key
68
+ @type_name = type_name
69
+ @federation = federation
70
+ end
71
+ end
72
+ end
73
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "./composer/base_validator"
4
4
  require_relative "./composer/validate_interfaces"
5
5
  require_relative "./composer/validate_boundaries"
6
+ require_relative "./composer/boundary_config"
6
7
 
7
8
  module GraphQL
8
9
  module Stitching
@@ -61,7 +62,7 @@ module GraphQL
61
62
  @default_value_merger = default_value_merger || BASIC_VALUE_MERGER
62
63
  @directive_kwarg_merger = directive_kwarg_merger || BASIC_VALUE_MERGER
63
64
  @root_field_location_selector = root_field_location_selector || BASIC_ROOT_FIELD_LOCATION_SELECTOR
64
- @stitch_directives = {}
65
+ @boundary_configs = {}
65
66
 
66
67
  @field_map = nil
67
68
  @boundary_map = nil
@@ -187,37 +188,8 @@ module GraphQL
187
188
  raise ComposerError, "The schema for `#{location}` location must be a GraphQL::Schema class."
188
189
  end
189
190
 
190
- input.fetch(:stitch, GraphQL::Stitching::EMPTY_ARRAY).each do |dir|
191
- type = dir[:parent_type_name] ? schema.types[dir[:parent_type_name]] : schema.query
192
- raise ComposerError, "Invalid stitch directive type `#{dir[:parent_type_name]}`" unless type
193
-
194
- field = type.fields[dir[:field_name]]
195
- raise ComposerError, "Invalid stitch directive field `#{dir[:field_name]}`" unless field
196
-
197
- field_path = "#{location}.#{field.name}"
198
- @stitch_directives[field_path] ||= []
199
- @stitch_directives[field_path] << dir.slice(:key, :type_name)
200
- end
201
-
202
- federation_entity_type = schema.types["_Entity"]
203
- if federation_entity_type && federation_entity_type.kind.union? && schema.query.fields["_entities"]&.type&.unwrap == federation_entity_type
204
- schema.possible_types(federation_entity_type).each do |entity_type|
205
- entity_type.directives.each do |directive|
206
- next unless directive.graphql_name == "key"
207
-
208
- key = directive.arguments.keyword_arguments.fetch(:fields).strip
209
- raise ComposerError, "Composite federation keys are not supported." unless /^\w+$/.match?(key)
210
-
211
- field_path = "#{location}._entities"
212
- @stitch_directives[field_path] ||= []
213
- @stitch_directives[field_path] << {
214
- key: key,
215
- type_name: entity_type.graphql_name,
216
- federation: true,
217
- }
218
- end
219
- end
220
- end
191
+ @boundary_configs.merge!(BoundaryConfig.extract_directive_assignments(schema, location, input[:stitch]))
192
+ @boundary_configs.merge!(BoundaryConfig.extract_federation_entities(schema, location))
221
193
 
222
194
  schemas[location.to_s] = schema
223
195
  executables[location.to_s] = input[:executable] || schema
@@ -557,19 +529,17 @@ module GraphQL
557
529
  def extract_boundaries(type_name, types_by_location)
558
530
  types_by_location.each do |location, type_candidate|
559
531
  type_candidate.fields.each do |field_name, field_candidate|
560
- boundary_type_name = field_candidate.type.unwrap.graphql_name
532
+ boundary_type = field_candidate.type.unwrap
561
533
  boundary_structure = Util.flatten_type_structure(field_candidate.type)
562
- boundary_kwargs = @stitch_directives["#{location}.#{field_name}"] || []
534
+ boundary_configs = @boundary_configs.fetch("#{location}.#{field_name}", [])
563
535
 
564
536
  field_candidate.directives.each do |directive|
565
537
  next unless directive.graphql_name == GraphQL::Stitching.stitch_directive
566
- boundary_kwargs << directive.arguments.keyword_arguments
538
+ boundary_configs << BoundaryConfig.from_kwargs(directive.arguments.keyword_arguments)
567
539
  end
568
540
 
569
- boundary_kwargs.each do |kwargs|
570
- key = kwargs.fetch(:key)
571
- impl_type_name = kwargs.fetch(:type_name, boundary_type_name)
572
- key_selections = GraphQL.parse("{ #{key} }").definitions[0].selections
541
+ boundary_configs.each do |config|
542
+ key_selections = GraphQL.parse("{ #{config.key} }").definitions[0].selections
573
543
 
574
544
  if key_selections.length != 1
575
545
  raise ComposerError, "Boundary key at #{type_name}.#{field_name} must specify exactly one key."
@@ -578,6 +548,8 @@ module GraphQL
578
548
  argument_name = key_selections[0].alias
579
549
  argument_name ||= if field_candidate.arguments.size == 1
580
550
  field_candidate.arguments.keys.first
551
+ elsif field_candidate.arguments[config.key]
552
+ config.key
581
553
  end
582
554
 
583
555
  argument = field_candidate.arguments[argument_name]
@@ -591,15 +563,26 @@ module GraphQL
591
563
  raise ComposerError, "Mismatched input/output for #{type_name}.#{field_name}.#{argument_name} boundary. Arguments must map directly to results."
592
564
  end
593
565
 
594
- @boundary_map[impl_type_name] ||= []
595
- @boundary_map[impl_type_name] << Boundary.new(
566
+ boundary_type_name = if config.type_name
567
+ if !boundary_type.kind.abstract?
568
+ raise ComposerError, "Resolver config may only specify a type name for abstract resolvers."
569
+ elsif !boundary_type.possible_types.find { _1.graphql_name == config.type_name }
570
+ raise ComposerError, "Type `#{config.type_name}` is not a possible return type for query `#{field_name}`."
571
+ end
572
+ config.type_name
573
+ else
574
+ boundary_type.graphql_name
575
+ end
576
+
577
+ @boundary_map[boundary_type_name] ||= []
578
+ @boundary_map[boundary_type_name] << Boundary.new(
596
579
  location: location,
597
- type_name: impl_type_name,
580
+ type_name: boundary_type_name,
598
581
  key: key_selections[0].name,
599
582
  field: field_candidate.name,
600
583
  arg: argument_name,
601
584
  list: boundary_structure.first.list?,
602
- federation: kwargs[:federation] || false,
585
+ federation: config.federation,
603
586
  )
604
587
  end
605
588
  end
@@ -5,6 +5,7 @@ module GraphQL::Stitching
5
5
  class ResolverDirective < GraphQL::Schema::Directive
6
6
  graphql_name "resolver"
7
7
  locations OBJECT, INTERFACE, UNION
8
+ argument :type_name, String, required: false
8
9
  argument :location, String, required: true
9
10
  argument :key, String, required: true
10
11
  argument :field, String, required: true
@@ -31,7 +31,7 @@ module GraphQL
31
31
  kwargs = directive.arguments.keyword_arguments
32
32
  boundary_map[type_name] ||= []
33
33
  boundary_map[type_name] << Boundary.new(
34
- type_name: type_name,
34
+ type_name: kwargs.fetch(:type_name, type_name),
35
35
  location: kwargs[:location],
36
36
  key: kwargs[:key],
37
37
  field: kwargs[:field],
@@ -134,6 +134,7 @@ module GraphQL
134
134
  end
135
135
 
136
136
  type.directive(ResolverDirective, **{
137
+ type_name: (boundary.type_name if boundary.type_name != type_name),
137
138
  location: boundary.location,
138
139
  key: boundary.key,
139
140
  field: boundary.field,
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Stitching
5
- VERSION = "1.2.3"
5
+ VERSION = "1.2.4"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql-stitching
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.3
4
+ version: 1.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Greg MacWilliam
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-04-06 00:00:00.000000000 Z
11
+ date: 2024-04-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: graphql
@@ -114,6 +114,7 @@ files:
114
114
  - lib/graphql/stitching/client.rb
115
115
  - lib/graphql/stitching/composer.rb
116
116
  - lib/graphql/stitching/composer/base_validator.rb
117
+ - lib/graphql/stitching/composer/boundary_config.rb
117
118
  - lib/graphql/stitching/composer/validate_boundaries.rb
118
119
  - lib/graphql/stitching/composer/validate_interfaces.rb
119
120
  - lib/graphql/stitching/executor.rb