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 +4 -4
- data/README.md +34 -13
- data/lib/graphql/stitching/composer/boundary_config.rb +73 -0
- data/lib/graphql/stitching/composer.rb +26 -43
- data/lib/graphql/stitching/supergraph/resolver_directive.rb +1 -0
- data/lib/graphql/stitching/supergraph.rb +2 -1
- data/lib/graphql/stitching/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5c1c6ae95fd6ec036a7678356c8bc48191d8cf5cfd9e4b2936e79fdb72b52f48
|
4
|
+
data.tar.gz: 7f8002d013e26f69df5a7023e147f98b86a46cc3027d2753b6d704bfe75a835b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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-
|
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
|
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
|
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(
|
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!
|
214
|
-
type Product {
|
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
|
-
|
243
|
+
sku: ID!
|
223
244
|
}
|
224
245
|
type Query {
|
225
246
|
productById(id: ID!): Product @stitch(key: "id")
|
226
|
-
productByUpc(
|
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
|
-
|
256
|
+
sku: ID!
|
236
257
|
}
|
237
258
|
type Query {
|
238
|
-
product(id: ID,
|
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
|
-
|
293
|
+
sku: ID!
|
273
294
|
}
|
274
295
|
type Query {
|
275
296
|
productById(id: ID!): Product
|
276
|
-
productByUpc(
|
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: "
|
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
|
-
@
|
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
|
-
|
191
|
-
|
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
|
-
|
532
|
+
boundary_type = field_candidate.type.unwrap
|
561
533
|
boundary_structure = Util.flatten_type_structure(field_candidate.type)
|
562
|
-
|
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
|
-
|
538
|
+
boundary_configs << BoundaryConfig.from_kwargs(directive.arguments.keyword_arguments)
|
567
539
|
end
|
568
540
|
|
569
|
-
|
570
|
-
|
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
|
-
|
595
|
-
|
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:
|
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:
|
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,
|
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.
|
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-
|
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
|