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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10489fd6a8670d5a23a7afa132d7941a242848783f3697a4d3ddd519b208a4d8
4
- data.tar.gz: 7a2e8dda124bdc6e96da43e1a4ed64bbb4baeaf0065f6d8411edaeba2c52e064
3
+ metadata.gz: 33a2615122207bdf93a7df873fe1efe7a7f3c13050f59dabc6e85becd75cf96e
4
+ data.tar.gz: 9f203624ab5c26a3cb6ab4710759ef7cb76c1890fff8f4849ab31404875fe79a
5
5
  SHA512:
6
- metadata.gz: 4c9243880e3b41fcede7fddb5947a962f1d4c43882ba07cc0ab63d1ba154527ef4cc8e5cc130bb2524b40fcbe093ecfd2f3b8fb0bafc9a2a7324050c30d2af00
7
- data.tar.gz: a2b37c3ab8b98065a0910a458e177b71576c5d8f52c6f6eba0e31d25ae7797f1517a168aeef2f39c7295d27e4f981373b7a90066dc7ab8651670ecbd41fc70d5
6
+ metadata.gz: ad1afa71d6525ea79857106eca6c347f77fab8141dd9a504f3da1a6631f598fe7fb154bc446e228e420fa46dff993140ff568fc3dc94af7a76f0cf29da550896
7
+ data.tar.gz: 2632af6a1347f1fb6513c79781c3959aacdd879583519cb31e69834a48f2ae70cafdb778d6915713d0d19bf535a67cac55763377cc27da0795b80ed6abc298d4
data/.gitignore CHANGED
@@ -25,6 +25,8 @@ Gemfile.lock
25
25
  .dat*
26
26
  .repl_history
27
27
  build/
28
+ node_modules/
29
+ package-lock.json
28
30
  *.bridgesupport
29
31
  build-iPhoneOS/
30
32
  build-iPhoneSimulator/
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 assembles a stitched graph ready to execute requests:
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"]}` found in #{boundary["location"]}.
37
- Limit one boundary query per type and key in each location. Abstract boundaries provide all possible types."
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
- All locations must provide a boundary accessor that uses a conjoining key."
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
- or else define boundary queries so that its unique fields may be accessed remotely."
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
- # @todo
9
- # Validate all supergraph interface fields
10
- # match possible types in all locations...
11
- # - Traverse supergraph types (supergraph.types)
12
- # - For each interface (.kind.interface?), get possible types (Util.get_possible_types)
13
- # - For each possible type, traverse type candidates (composer.subschema_types_by_name_and_location)
14
- # - For each type candidate, compare interface fields to type candidate fields
15
- # - For each type candidate field that matches an interface field...
16
- # - Named types must match
17
- # - List structures must match
18
- # - Nullabilities must be >= interface field
19
- # - It's OKAY if a type candidate does not implement the full interface
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
- unless kinds.all? { _1 == kinds.first }
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: #{kind}."
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: merge_value_types(type_name, value_types, field_name: field_name),
315
- null: !value_types.all?(&:non_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: merge_value_types(type_name, value_types, argument_name: argument_name, field_name: field_name),
353
- required: value_types.any?(&:non_null?),
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
- named_types = type_candidates.map { _1.unwrap.graphql_name }.uniq
406
+ alt_structures = type_candidates.map { Util.flatten_type_structure(_1) }
407
+ basis_structure = alt_structures.shift
405
408
 
406
- unless named_types.all? { _1 == named_types.first }
407
- raise ComposerError, "Cannot compose mixed types at `#{path}`. Found: #{named_types.join(", ")}."
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
- list_structures.each(&:reverse!)
419
- list_structures.first.each_with_index do |current, index|
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
- boundary_list = Util.get_list_structure(field_candidate.type)
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
- argument_list = Util.get_list_structure(argument.type)
486
- if argument_list.length != boundary_list.length
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" => boundary_list.any?,
493
+ "list" => boundary_structure.first[:list],
497
494
  "type_name" => boundary_type_name,
498
495
  }
499
496
  end
@@ -35,9 +35,9 @@ module GraphQL
35
35
  return error_result(validation_errors) if validation_errors.any?
36
36
  end
37
37
 
38
- request.prepare!
39
-
40
38
  begin
39
+ request.prepare!
40
+
41
41
  plan = fetch_plan(request) do
42
42
  GraphQL::Stitching::Planner.new(
43
43
  supergraph: @supergraph,
@@ -20,13 +20,11 @@ module GraphQL
20
20
  end
21
21
 
22
22
  def operations
23
- ops = @operations_by_grouping.values
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 climbing selects highest scoring locations to use
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
- GraphQL::Language::Printer.new.print(op).gsub!(/\s+/, " ").strip!
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] = GraphQL::Language::Printer.new.print(value_type)
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 fragment_matches_typename?(fragment_type, typename)
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 fragment_matches_typename?(fragment_type, typename)
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 fragment_matches_typename?(fragment_type, typename)
97
- return true if fragment_type.graphql_name == typename
98
- fragment_type.kind.interface? && @schema.possible_types(fragment_type).any? { _1.graphql_name == typename }
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.is_a?(GraphQL::Schema::NonNull)
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Stitching
5
- VERSION = "0.3.1"
5
+ VERSION = "0.3.2"
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: 0.3.1
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-03 00:00:00.000000000 Z
11
+ date: 2023-03-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: graphql