graphql-stitching 0.3.1 → 0.3.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/README.md +1 -1
- data/lib/graphql/stitching/composer/validate_boundaries.rb +7 -6
- data/lib/graphql/stitching/composer/validate_interfaces.rb +40 -12
- data/lib/graphql/stitching/composer.rb +31 -34
- data/lib/graphql/stitching/gateway.rb +2 -2
- data/lib/graphql/stitching/planner.rb +5 -5
- data/lib/graphql/stitching/planner_operation.rb +4 -2
- data/lib/graphql/stitching/shaper.rb +5 -5
- data/lib/graphql/stitching/util.rb +24 -22
- data/lib/graphql/stitching/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 33a2615122207bdf93a7df873fe1efe7a7f3c13050f59dabc6e85becd75cf96e
|
4
|
+
data.tar.gz: 9f203624ab5c26a3cb6ab4710759ef7cb76c1890fff8f4849ab31404875fe79a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ad1afa71d6525ea79857106eca6c347f77fab8141dd9a504f3da1a6631f598fe7fb154bc446e228e420fa46dff993140ff568fc3dc94af7a76f0cf29da550896
|
7
|
+
data.tar.gz: 2632af6a1347f1fb6513c79781c3959aacdd879583519cb31e69834a48f2ae70cafdb778d6915713d0d19bf535a67cac55763377cc27da0795b80ed6abc298d4
|
data/.gitignore
CHANGED
data/README.md
CHANGED
@@ -32,7 +32,7 @@ require "graphql/stitching"
|
|
32
32
|
|
33
33
|
## Usage
|
34
34
|
|
35
|
-
The quickest way to start is to use the provided [`Gateway`](./docs/gateway.md) component that
|
35
|
+
The quickest way to start is to use the provided [`Gateway`](./docs/gateway.md) component that wraps a stitched graph in an executable workflow with [caching hooks](./docs/gateway.md#cache-hooks):
|
36
36
|
|
37
37
|
```ruby
|
38
38
|
movies_schema = <<~GRAPHQL
|
@@ -33,8 +33,9 @@ module GraphQL
|
|
33
33
|
# only one boundary allowed per type/location/key
|
34
34
|
boundaries_by_location_and_key = boundaries.each_with_object({}) do |boundary, memo|
|
35
35
|
if memo.dig(boundary["location"], boundary["selection"])
|
36
|
-
raise Composer::ValidationError, "Multiple boundary queries for `#{type.graphql_name}.#{boundary["selection"]}`
|
37
|
-
|
36
|
+
raise Composer::ValidationError, "Multiple boundary queries for `#{type.graphql_name}.#{boundary["selection"]}` "\
|
37
|
+
"found in #{boundary["location"]}. Limit one boundary query per type and key in each location. "\
|
38
|
+
"Abstract boundaries provide all possible types."
|
38
39
|
end
|
39
40
|
memo[boundary["location"]] ||= {}
|
40
41
|
memo[boundary["location"]][boundary["selection"]] = boundary
|
@@ -60,8 +61,8 @@ module GraphQL
|
|
60
61
|
remote_locations = bidirectional_access_locations.reject { _1 == location }
|
61
62
|
paths = ctx.route_type_to_locations(type.graphql_name, location, remote_locations)
|
62
63
|
if paths.length != remote_locations.length || paths.any? { |_loc, path| path.nil? }
|
63
|
-
raise Composer::ValidationError, "Cannot route `#{type.graphql_name}` boundaries in #{location} to all other locations.
|
64
|
-
|
64
|
+
raise Composer::ValidationError, "Cannot route `#{type.graphql_name}` boundaries in #{location} to all other locations. "\
|
65
|
+
"All locations must provide a boundary accessor that uses a conjoining key."
|
65
66
|
end
|
66
67
|
end
|
67
68
|
end
|
@@ -80,8 +81,8 @@ module GraphQL
|
|
80
81
|
|
81
82
|
subschema_types_by_location.each do |location, subschema_type|
|
82
83
|
if subschema_type.fields.keys.sort != expected_fields
|
83
|
-
raise Composer::ValidationError, "Shared type `#{type.graphql_name}` must have consistent fields across locations,
|
84
|
-
|
84
|
+
raise Composer::ValidationError, "Shared type `#{type.graphql_name}` must have consistent fields across locations, "\
|
85
|
+
"or else define boundary queries so that its unique fields may be accessed remotely."
|
85
86
|
end
|
86
87
|
end
|
87
88
|
end
|
@@ -4,19 +4,47 @@ module GraphQL
|
|
4
4
|
module Stitching
|
5
5
|
class Composer::ValidateInterfaces < Composer::BaseValidator
|
6
6
|
|
7
|
+
# For each composed interface, check the interface against each possible type
|
8
|
+
# to assure that intersecting fields have compatible types, structures, and nullability.
|
9
|
+
# Verifies compatibility of types that inherit interface contracts through merging.
|
7
10
|
def perform(supergraph, composer)
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
11
|
+
supergraph.schema.types.each do |type_name, interface_type|
|
12
|
+
next unless interface_type.kind.interface?
|
13
|
+
|
14
|
+
supergraph.schema.possible_types(interface_type).each do |possible_type|
|
15
|
+
interface_type.fields.each do |field_name, interface_field|
|
16
|
+
# graphql-ruby will dynamically apply interface fields on a type implementation,
|
17
|
+
# so check the delegation map to assure that all materialized fields have resolver locations.
|
18
|
+
unless supergraph.locations_by_type_and_field[possible_type.graphql_name][field_name]&.any?
|
19
|
+
raise Composer::ValidationError, "Type #{possible_type.graphql_name} does not implement a `#{field_name}` field in any location, "\
|
20
|
+
"which is required by interface #{interface_type.graphql_name}."
|
21
|
+
end
|
22
|
+
|
23
|
+
intersecting_field = possible_type.fields[field_name]
|
24
|
+
interface_type_structure = Util.flatten_type_structure(interface_field.type)
|
25
|
+
possible_type_structure = Util.flatten_type_structure(intersecting_field.type)
|
26
|
+
|
27
|
+
if possible_type_structure.length != interface_type_structure.length
|
28
|
+
raise Composer::ValidationError, "Incompatible list structures between field #{possible_type.graphql_name}.#{field_name} of type "\
|
29
|
+
"#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
|
30
|
+
end
|
31
|
+
|
32
|
+
interface_type_structure.each_with_index do |interface_struct, index|
|
33
|
+
possible_struct = possible_type_structure[index]
|
34
|
+
|
35
|
+
if possible_struct[:name] != interface_struct[:name]
|
36
|
+
raise Composer::ValidationError, "Incompatible named types between field #{possible_type.graphql_name}.#{field_name} of type "\
|
37
|
+
"#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
|
38
|
+
end
|
39
|
+
|
40
|
+
if possible_struct[:null] && !interface_struct[:null]
|
41
|
+
raise Composer::ValidationError, "Incompatible nullability between field #{possible_type.graphql_name}.#{field_name} of type "\
|
42
|
+
"#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
20
48
|
end
|
21
49
|
|
22
50
|
end
|
@@ -77,7 +77,7 @@ module GraphQL
|
|
77
77
|
schema_types = @subschema_types_by_name_and_location.each_with_object({}) do |(type_name, types_by_location), memo|
|
78
78
|
kinds = types_by_location.values.map { _1.kind.name }.uniq
|
79
79
|
|
80
|
-
|
80
|
+
if kinds.length > 1
|
81
81
|
raise ComposerError, "Cannot merge different kinds for `#{type_name}`. Found: #{kinds.join(", ")}."
|
82
82
|
end
|
83
83
|
|
@@ -96,7 +96,7 @@ module GraphQL
|
|
96
96
|
when "INPUT_OBJECT"
|
97
97
|
build_input_object_type(type_name, types_by_location)
|
98
98
|
else
|
99
|
-
raise ComposerError, "Unexpected kind encountered for `#{type_name}`. Found: #{
|
99
|
+
raise ComposerError, "Unexpected kind encountered for `#{type_name}`. Found: #{kinds.first}."
|
100
100
|
end
|
101
101
|
end
|
102
102
|
|
@@ -307,12 +307,13 @@ module GraphQL
|
|
307
307
|
fields_by_name_location.each do |field_name, fields_by_location|
|
308
308
|
value_types = fields_by_location.values.map(&:type)
|
309
309
|
|
310
|
+
type = merge_value_types(type_name, value_types, field_name: field_name)
|
310
311
|
schema_field = owner.field(
|
311
312
|
field_name,
|
312
313
|
description: merge_descriptions(type_name, fields_by_location, field_name: field_name),
|
313
314
|
deprecation_reason: merge_deprecations(type_name, fields_by_location, field_name: field_name),
|
314
|
-
type:
|
315
|
-
null: !
|
315
|
+
type: Util.unwrap_non_null(type),
|
316
|
+
null: !type.non_null?,
|
316
317
|
camelize: false,
|
317
318
|
)
|
318
319
|
|
@@ -345,12 +346,13 @@ module GraphQL
|
|
345
346
|
# Getting double args sometimes... why?
|
346
347
|
return if owner.arguments.any? { _1.first == argument_name }
|
347
348
|
|
349
|
+
type = merge_value_types(type_name, value_types, argument_name: argument_name, field_name: field_name)
|
348
350
|
schema_argument = owner.argument(
|
349
351
|
argument_name,
|
350
352
|
description: merge_descriptions(type_name, arguments_by_location, argument_name: argument_name, field_name: field_name),
|
351
353
|
deprecation_reason: merge_deprecations(type_name, arguments_by_location, argument_name: argument_name, field_name: field_name),
|
352
|
-
type:
|
353
|
-
required:
|
354
|
+
type: Util.unwrap_non_null(type),
|
355
|
+
required: type.non_null?,
|
354
356
|
camelize: false,
|
355
357
|
)
|
356
358
|
|
@@ -401,37 +403,32 @@ module GraphQL
|
|
401
403
|
|
402
404
|
def merge_value_types(type_name, type_candidates, field_name: nil, argument_name: nil)
|
403
405
|
path = [type_name, field_name, argument_name].compact.join(".")
|
404
|
-
|
406
|
+
alt_structures = type_candidates.map { Util.flatten_type_structure(_1) }
|
407
|
+
basis_structure = alt_structures.shift
|
405
408
|
|
406
|
-
|
407
|
-
|
408
|
-
end
|
409
|
-
|
410
|
-
type = GraphQL::Schema::BUILT_IN_TYPES.fetch(named_types.first, build_type_binding(named_types.first))
|
411
|
-
list_structures = type_candidates.map { Util.get_list_structure(_1) }
|
412
|
-
|
413
|
-
if list_structures.any?(&:any?)
|
414
|
-
if list_structures.any? { _1.length != list_structures.first.length }
|
409
|
+
alt_structures.each do |alt_structure|
|
410
|
+
if alt_structure.length != basis_structure.length
|
415
411
|
raise ComposerError, "Cannot compose mixed list structures at `#{path}`."
|
416
412
|
end
|
417
413
|
|
418
|
-
|
419
|
-
|
420
|
-
# input arguments use strongest nullability, readonly fields use weakest
|
421
|
-
non_null = list_structures.public_send(argument_name ? :any? : :all?) do |list_structure|
|
422
|
-
list_structure[index].start_with?("non_null")
|
423
|
-
end
|
424
|
-
|
425
|
-
case current
|
426
|
-
when "list", "non_null_list"
|
427
|
-
type = type.to_list_type
|
428
|
-
type = type.to_non_null_type if non_null
|
429
|
-
when "element", "non_null_element"
|
430
|
-
type = type.to_non_null_type if non_null
|
431
|
-
end
|
414
|
+
if alt_structure.last[:name] != basis_structure.last[:name]
|
415
|
+
raise ComposerError, "Cannot compose mixed types at `#{path}`."
|
432
416
|
end
|
433
417
|
end
|
434
418
|
|
419
|
+
type = GraphQL::Schema::BUILT_IN_TYPES.fetch(
|
420
|
+
basis_structure.last[:name],
|
421
|
+
build_type_binding(basis_structure.last[:name])
|
422
|
+
)
|
423
|
+
|
424
|
+
basis_structure.reverse!.each_with_index do |basis, index|
|
425
|
+
rev_index = basis_structure.length - index - 1
|
426
|
+
non_null = alt_structures.each_with_object([!basis[:null]]) { |s, m| m << !s[rev_index][:null] }
|
427
|
+
|
428
|
+
type = type.to_list_type if basis[:list]
|
429
|
+
type = type.to_non_null_type if argument_name ? non_null.any? : non_null.all?
|
430
|
+
end
|
431
|
+
|
435
432
|
type
|
436
433
|
end
|
437
434
|
|
@@ -459,7 +456,7 @@ module GraphQL
|
|
459
456
|
types_by_location.each do |location, type_candidate|
|
460
457
|
type_candidate.fields.each do |field_name, field_candidate|
|
461
458
|
boundary_type_name = field_candidate.type.unwrap.graphql_name
|
462
|
-
|
459
|
+
boundary_structure = Util.flatten_type_structure(field_candidate.type)
|
463
460
|
|
464
461
|
field_candidate.directives.each do |directive|
|
465
462
|
next unless directive.graphql_name == GraphQL::Stitching.stitch_directive
|
@@ -482,8 +479,8 @@ module GraphQL
|
|
482
479
|
raise ComposerError, "Invalid boundary argument `#{argument_name}` for #{type_name}.#{field_name}."
|
483
480
|
end
|
484
481
|
|
485
|
-
|
486
|
-
if
|
482
|
+
argument_structure = Util.flatten_type_structure(argument.type)
|
483
|
+
if argument_structure.length != boundary_structure.length
|
487
484
|
raise ComposerError, "Mismatched input/output for #{type_name}.#{field_name}.#{argument_name} boundary. Arguments must map directly to results."
|
488
485
|
end
|
489
486
|
|
@@ -493,7 +490,7 @@ module GraphQL
|
|
493
490
|
"selection" => key_selections[0].name,
|
494
491
|
"field" => field_candidate.name,
|
495
492
|
"arg" => argument_name,
|
496
|
-
"list" =>
|
493
|
+
"list" => boundary_structure.first[:list],
|
497
494
|
"type_name" => boundary_type_name,
|
498
495
|
}
|
499
496
|
end
|
@@ -20,13 +20,11 @@ module GraphQL
|
|
20
20
|
end
|
21
21
|
|
22
22
|
def operations
|
23
|
-
|
24
|
-
ops.sort_by!(&:key)
|
25
|
-
ops
|
23
|
+
@operations_by_grouping.values.sort_by!(&:key)
|
26
24
|
end
|
27
25
|
|
28
26
|
def to_h
|
29
|
-
{ "ops" => operations.map(&:to_h) }
|
27
|
+
{ "ops" => operations.map!(&:to_h) }
|
30
28
|
end
|
31
29
|
|
32
30
|
private
|
@@ -250,7 +248,7 @@ module GraphQL
|
|
250
248
|
possible_locations = possible_locations_by_field[node.name]
|
251
249
|
preferred_location_score = 0
|
252
250
|
|
253
|
-
# hill
|
251
|
+
# hill-climb to select highest scoring location for each field
|
254
252
|
preferred_location = possible_locations.reduce(possible_locations.first) do |best_location, possible_location|
|
255
253
|
score = selections_by_location[possible_location] ? remote_selections.length : 0
|
256
254
|
score += location_weights.fetch(possible_location, 0)
|
@@ -268,6 +266,8 @@ module GraphQL
|
|
268
266
|
end
|
269
267
|
end
|
270
268
|
|
269
|
+
# route from current location to target locations via boundary queries,
|
270
|
+
# then translate those routes into planner operations
|
271
271
|
routes = @supergraph.route_type_to_locations(parent_type.graphql_name, current_location, selections_by_location.keys)
|
272
272
|
routes.values.each_with_object({}) do |route, ops_by_location|
|
273
273
|
route.reduce(nil) do |parent_op, boundary|
|
@@ -3,6 +3,8 @@
|
|
3
3
|
module GraphQL
|
4
4
|
module Stitching
|
5
5
|
class PlannerOperation
|
6
|
+
LANGUAGE_PRINTER = GraphQL::Language::Printer.new
|
7
|
+
|
6
8
|
attr_reader :key, :location, :parent_type, :type_condition, :operation_type, :insertion_path
|
7
9
|
attr_accessor :after_key, :selections, :variables, :boundary
|
8
10
|
|
@@ -32,12 +34,12 @@ module GraphQL
|
|
32
34
|
|
33
35
|
def selection_set
|
34
36
|
op = GraphQL::Language::Nodes::OperationDefinition.new(selections: @selections)
|
35
|
-
|
37
|
+
LANGUAGE_PRINTER.print(op).gsub!(/\s+/, " ").strip!
|
36
38
|
end
|
37
39
|
|
38
40
|
def variable_set
|
39
41
|
@variables.each_with_object({}) do |(variable_name, value_type), memo|
|
40
|
-
memo[variable_name] =
|
42
|
+
memo[variable_name] = LANGUAGE_PRINTER.print(value_type)
|
41
43
|
end
|
42
44
|
end
|
43
45
|
|
@@ -43,7 +43,7 @@ module GraphQL
|
|
43
43
|
|
44
44
|
when GraphQL::Language::Nodes::InlineFragment
|
45
45
|
fragment_type = @schema.types[node.type.name]
|
46
|
-
next unless
|
46
|
+
next unless typename_in_type?(typename, fragment_type)
|
47
47
|
|
48
48
|
result = resolve_object_scope(raw_object, fragment_type, node.selections, typename)
|
49
49
|
return nil if result.nil?
|
@@ -51,7 +51,7 @@ module GraphQL
|
|
51
51
|
when GraphQL::Language::Nodes::FragmentSpread
|
52
52
|
fragment = @request.fragment_definitions[node.name]
|
53
53
|
fragment_type = @schema.types[fragment.type.name]
|
54
|
-
next unless
|
54
|
+
next unless typename_in_type?(typename, fragment_type)
|
55
55
|
|
56
56
|
result = resolve_object_scope(raw_object, fragment_type, fragment.selections, typename)
|
57
57
|
return nil if result.nil?
|
@@ -93,9 +93,9 @@ module GraphQL
|
|
93
93
|
resolved_list
|
94
94
|
end
|
95
95
|
|
96
|
-
def
|
97
|
-
return true if
|
98
|
-
|
96
|
+
def typename_in_type?(typename, type)
|
97
|
+
return true if type.graphql_name == typename
|
98
|
+
type.kind.abstract? && @schema.possible_types(type).any? { _1.graphql_name == typename }
|
99
99
|
end
|
100
100
|
end
|
101
101
|
end
|
@@ -10,12 +10,33 @@ module GraphQL
|
|
10
10
|
|
11
11
|
# strips non-null wrappers from a type
|
12
12
|
def self.unwrap_non_null(type)
|
13
|
-
while type.
|
14
|
-
type = type.of_type
|
15
|
-
end
|
13
|
+
type = type.of_type while type.non_null?
|
16
14
|
type
|
17
15
|
end
|
18
16
|
|
17
|
+
# builds a single-dimensional representation of a wrapped type structure
|
18
|
+
def self.flatten_type_structure(type)
|
19
|
+
structure = []
|
20
|
+
|
21
|
+
while type.list?
|
22
|
+
structure << {
|
23
|
+
list: true,
|
24
|
+
null: !type.non_null?,
|
25
|
+
name: nil,
|
26
|
+
}
|
27
|
+
|
28
|
+
type = unwrap_non_null(type).of_type
|
29
|
+
end
|
30
|
+
|
31
|
+
structure << {
|
32
|
+
list: false,
|
33
|
+
null: !type.non_null?,
|
34
|
+
name: type.unwrap.graphql_name,
|
35
|
+
}
|
36
|
+
|
37
|
+
structure
|
38
|
+
end
|
39
|
+
|
19
40
|
# gets a named type for a field node, including hidden root introspections
|
20
41
|
def self.named_type_for_field_node(schema, parent_type, node)
|
21
42
|
if node.name == "__schema" && parent_type == schema.query
|
@@ -40,25 +61,6 @@ module GraphQL
|
|
40
61
|
end
|
41
62
|
result.uniq
|
42
63
|
end
|
43
|
-
|
44
|
-
# gets a deep structural description of a list value type
|
45
|
-
def self.get_list_structure(type)
|
46
|
-
structure = []
|
47
|
-
previous = nil
|
48
|
-
while type.respond_to?(:of_type)
|
49
|
-
if type.is_a?(GraphQL::Schema::List)
|
50
|
-
structure.push(previous.is_a?(GraphQL::Schema::NonNull) ? "non_null_list" : "list")
|
51
|
-
end
|
52
|
-
if structure.any?
|
53
|
-
previous = type
|
54
|
-
if !type.of_type.respond_to?(:of_type)
|
55
|
-
structure.push(previous.is_a?(GraphQL::Schema::NonNull) ? "non_null_element" : "element")
|
56
|
-
end
|
57
|
-
end
|
58
|
-
type = type.of_type
|
59
|
-
end
|
60
|
-
structure
|
61
|
-
end
|
62
64
|
end
|
63
65
|
end
|
64
66
|
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: 0.3.
|
4
|
+
version: 0.3.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Greg MacWilliam
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-03-
|
11
|
+
date: 2023-03-09 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: graphql
|