graphql-stitching 0.3.1 → 0.3.2

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: 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