graphql-stitching 1.4.3 → 1.5.1

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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +4 -0
  3. data/README.md +10 -7
  4. data/docs/README.md +1 -0
  5. data/docs/client.md +6 -0
  6. data/docs/composer.md +1 -1
  7. data/docs/subscriptions.md +208 -0
  8. data/docs/{resolver.md → type_resolver.md} +3 -3
  9. data/examples/subscriptions/.gitattributes +9 -0
  10. data/examples/subscriptions/.gitignore +35 -0
  11. data/examples/subscriptions/Gemfile +65 -0
  12. data/examples/subscriptions/README.md +38 -0
  13. data/examples/subscriptions/Rakefile +6 -0
  14. data/examples/subscriptions/app/channels/graphql_channel.rb +50 -0
  15. data/examples/subscriptions/app/controllers/graphql_controller.rb +44 -0
  16. data/examples/subscriptions/app/graphql/entities_schema.rb +42 -0
  17. data/examples/subscriptions/app/graphql/stitched_schema.rb +10 -0
  18. data/examples/subscriptions/app/graphql/subscriptions_schema.rb +54 -0
  19. data/examples/subscriptions/app/models/repository.rb +39 -0
  20. data/examples/subscriptions/app/views/graphql/client.html.erb +159 -0
  21. data/examples/subscriptions/bin/bundle +109 -0
  22. data/examples/subscriptions/bin/docker-entrypoint +8 -0
  23. data/examples/subscriptions/bin/importmap +4 -0
  24. data/examples/subscriptions/bin/rails +4 -0
  25. data/examples/subscriptions/bin/rake +4 -0
  26. data/examples/subscriptions/bin/setup +33 -0
  27. data/examples/subscriptions/config/application.rb +14 -0
  28. data/examples/subscriptions/config/boot.rb +4 -0
  29. data/examples/subscriptions/config/cable.yml +10 -0
  30. data/examples/subscriptions/config/credentials.yml.enc +1 -0
  31. data/examples/subscriptions/config/database.yml +25 -0
  32. data/examples/subscriptions/config/environment.rb +5 -0
  33. data/examples/subscriptions/config/environments/development.rb +74 -0
  34. data/examples/subscriptions/config/environments/production.rb +91 -0
  35. data/examples/subscriptions/config/environments/test.rb +64 -0
  36. data/examples/subscriptions/config/initializers/content_security_policy.rb +25 -0
  37. data/examples/subscriptions/config/initializers/filter_parameter_logging.rb +8 -0
  38. data/examples/subscriptions/config/initializers/inflections.rb +16 -0
  39. data/examples/subscriptions/config/initializers/permissions_policy.rb +13 -0
  40. data/examples/subscriptions/config/locales/en.yml +31 -0
  41. data/examples/subscriptions/config/master.key +1 -0
  42. data/examples/subscriptions/config/puma.rb +35 -0
  43. data/examples/subscriptions/config/routes.rb +8 -0
  44. data/examples/subscriptions/config/storage.yml +34 -0
  45. data/examples/subscriptions/config.ru +6 -0
  46. data/examples/subscriptions/db/seeds.rb +9 -0
  47. data/examples/subscriptions/public/404.html +17 -0
  48. data/examples/subscriptions/public/422.html +17 -0
  49. data/examples/subscriptions/public/500.html +16 -0
  50. data/examples/subscriptions/public/apple-touch-icon-precomposed.png +0 -0
  51. data/examples/subscriptions/public/apple-touch-icon.png +0 -0
  52. data/examples/subscriptions/public/favicon.ico +0 -0
  53. data/examples/subscriptions/public/robots.txt +1 -0
  54. data/lib/graphql/stitching/client.rb +18 -11
  55. data/lib/graphql/stitching/composer/{resolver_config.rb → type_resolver_config.rb} +3 -3
  56. data/lib/graphql/stitching/composer/{validate_resolvers.rb → validate_type_resolvers.rb} +8 -2
  57. data/lib/graphql/stitching/composer.rb +48 -42
  58. data/lib/graphql/stitching/executor/shaper.rb +3 -3
  59. data/lib/graphql/stitching/executor/{resolver_source.rb → type_resolver_source.rb} +2 -2
  60. data/lib/graphql/stitching/executor.rb +19 -11
  61. data/lib/graphql/stitching/http_executable.rb +3 -0
  62. data/lib/graphql/stitching/plan.rb +1 -1
  63. data/lib/graphql/stitching/planner/step.rb +1 -1
  64. data/lib/graphql/stitching/planner.rb +29 -15
  65. data/lib/graphql/stitching/{skip_include.rb → request/skip_include.rb} +3 -3
  66. data/lib/graphql/stitching/request.rb +44 -6
  67. data/lib/graphql/stitching/supergraph/to_definition.rb +3 -3
  68. data/lib/graphql/stitching/supergraph.rb +6 -3
  69. data/lib/graphql/stitching/{resolver → type_resolver}/arguments.rb +7 -7
  70. data/lib/graphql/stitching/{resolver → type_resolver}/keys.rb +3 -4
  71. data/lib/graphql/stitching/{resolver.rb → type_resolver.rb} +5 -5
  72. data/lib/graphql/stitching/util.rb +1 -0
  73. data/lib/graphql/stitching/version.rb +1 -1
  74. data/lib/graphql/stitching.rb +32 -4
  75. metadata +56 -10
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "./composer/base_validator"
4
- require_relative "./composer/validate_interfaces"
5
- require_relative "./composer/validate_resolvers"
6
- require_relative "./composer/resolver_config"
3
+ require_relative "composer/base_validator"
4
+ require_relative "composer/validate_interfaces"
5
+ require_relative "composer/validate_type_resolvers"
6
+ require_relative "composer/type_resolver_config"
7
7
 
8
8
  module GraphQL
9
9
  module Stitching
10
+ # Composer receives many individual `GraphQL::Schema` instances
11
+ # representing various graph locations and merges them into one
12
+ # combined Supergraph that is validated for integrity.
10
13
  class Composer
11
14
  # @api private
12
15
  NO_DEFAULT_VALUE = begin
@@ -26,9 +29,9 @@ module GraphQL
26
29
  BASIC_ROOT_FIELD_LOCATION_SELECTOR = ->(locations, _info) { locations.last }
27
30
 
28
31
  # @api private
29
- VALIDATORS = [
30
- "ValidateInterfaces",
31
- "ValidateResolvers",
32
+ COMPOSITION_VALIDATORS = [
33
+ ValidateInterfaces,
34
+ ValidateTypeResolvers,
32
35
  ].freeze
33
36
 
34
37
  # @return [String] name of the Query type in the composed schema.
@@ -37,6 +40,9 @@ module GraphQL
37
40
  # @return [String] name of the Mutation type in the composed schema.
38
41
  attr_reader :mutation_name
39
42
 
43
+ # @return [String] name of the Subscription type in the composed schema.
44
+ attr_reader :subscription_name
45
+
40
46
  # @api private
41
47
  attr_reader :subgraph_types_by_name_and_location
42
48
 
@@ -46,6 +52,7 @@ module GraphQL
46
52
  def initialize(
47
53
  query_name: "Query",
48
54
  mutation_name: "Mutation",
55
+ subscription_name: "Subscription",
49
56
  description_merger: nil,
50
57
  deprecation_merger: nil,
51
58
  default_value_merger: nil,
@@ -54,23 +61,27 @@ module GraphQL
54
61
  )
55
62
  @query_name = query_name
56
63
  @mutation_name = mutation_name
64
+ @subscription_name = subscription_name
57
65
  @description_merger = description_merger || BASIC_VALUE_MERGER
58
66
  @deprecation_merger = deprecation_merger || BASIC_VALUE_MERGER
59
67
  @default_value_merger = default_value_merger || BASIC_VALUE_MERGER
60
68
  @directive_kwarg_merger = directive_kwarg_merger || BASIC_VALUE_MERGER
61
69
  @root_field_location_selector = root_field_location_selector || BASIC_ROOT_FIELD_LOCATION_SELECTOR
70
+
71
+ @field_map = {}
72
+ @resolver_map = {}
62
73
  @resolver_configs = {}
63
-
64
- @field_map = nil
65
- @resolver_map = nil
66
- @mapped_type_names = nil
74
+ @mapped_type_names = {}
67
75
  @subgraph_directives_by_name_and_location = nil
68
76
  @subgraph_types_by_name_and_location = nil
69
77
  @schema_directives = nil
70
78
  end
71
79
 
72
80
  def perform(locations_input)
73
- reset!
81
+ if @subgraph_types_by_name_and_location
82
+ raise CompositionError, "Composer may only perform once per instance."
83
+ end
84
+
74
85
  schemas, executables = prepare_locations_input(locations_input)
75
86
 
76
87
  # "directive_name" => "location" => subgraph_directive
@@ -91,7 +102,6 @@ module GraphQL
91
102
  # "Typename" => "location" => subgraph_type
92
103
  @subgraph_types_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo|
93
104
  raise CompositionError, "Location keys must be strings" unless location.is_a?(String)
94
- raise CompositionError, "The subscription operation is not supported." if schema.subscription
95
105
 
96
106
  introspection_types = schema.introspection_system.types.keys
97
107
  schema.types.each do |type_name, subgraph_type|
@@ -101,10 +111,13 @@ module GraphQL
101
111
  raise CompositionError, "Query name \"#{@query_name}\" is used by non-query type in #{location} schema."
102
112
  elsif type_name == @mutation_name && subgraph_type != schema.mutation
103
113
  raise CompositionError, "Mutation name \"#{@mutation_name}\" is used by non-mutation type in #{location} schema."
114
+ elsif type_name == @subscription_name && subgraph_type != schema.subscription
115
+ raise CompositionError, "Subscription name \"#{@subscription_name}\" is used by non-subscription type in #{location} schema."
104
116
  end
105
117
 
106
118
  type_name = @query_name if subgraph_type == schema.query
107
119
  type_name = @mutation_name if subgraph_type == schema.mutation
120
+ type_name = @subscription_name if subgraph_type == schema.subscription
108
121
  @mapped_type_names[subgraph_type.graphql_name] = type_name if subgraph_type.graphql_name != type_name
109
122
 
110
123
  memo[type_name] ||= {}
@@ -148,13 +161,14 @@ module GraphQL
148
161
  orphan_types(schema_types.values.select { |t| t.respond_to?(:kind) && t.kind.object? })
149
162
  query schema_types[builder.query_name]
150
163
  mutation schema_types[builder.mutation_name]
164
+ subscription schema_types[builder.subscription_name]
151
165
  directives builder.schema_directives.values
152
166
 
153
167
  own_orphan_types.clear
154
168
  end
155
169
 
156
170
  select_root_field_locations(schema)
157
- expand_abstract_resolvers(schema)
171
+ expand_abstract_resolvers(schema, schemas)
158
172
 
159
173
  supergraph = Supergraph.new(
160
174
  schema: schema,
@@ -163,9 +177,8 @@ module GraphQL
163
177
  executables: executables,
164
178
  )
165
179
 
166
- VALIDATORS.each do |validator|
167
- klass = Object.const_get("GraphQL::Stitching::Composer::#{validator}")
168
- klass.new.perform(supergraph, self)
180
+ COMPOSITION_VALIDATORS.each do |validator_class|
181
+ validator_class.new.perform(supergraph, self)
169
182
  end
170
183
 
171
184
  supergraph
@@ -186,8 +199,8 @@ module GraphQL
186
199
  raise CompositionError, "The schema for `#{location}` location must be a GraphQL::Schema class."
187
200
  end
188
201
 
189
- @resolver_configs.merge!(ResolverConfig.extract_directive_assignments(schema, location, input[:stitch]))
190
- @resolver_configs.merge!(ResolverConfig.extract_federation_entities(schema, location))
202
+ @resolver_configs.merge!(TypeResolverConfig.extract_directive_assignments(schema, location, input[:stitch]))
203
+ @resolver_configs.merge!(TypeResolverConfig.extract_federation_entities(schema, location))
191
204
 
192
205
  schemas[location.to_s] = schema
193
206
  executables[location.to_s] = input[:executable] || schema
@@ -533,13 +546,13 @@ module GraphQL
533
546
 
534
547
  subgraph_field.directives.each do |directive|
535
548
  next unless directive.graphql_name == GraphQL::Stitching.stitch_directive
536
- resolver_configs << ResolverConfig.from_kwargs(directive.arguments.keyword_arguments)
549
+ resolver_configs << TypeResolverConfig.from_kwargs(directive.arguments.keyword_arguments)
537
550
  end
538
551
 
539
552
  resolver_configs.each do |config|
540
553
  resolver_type_name = if config.type_name
541
554
  if !resolver_type.kind.abstract?
542
- raise CompositionError, "Resolver config may only specify a type name for abstract resolvers."
555
+ raise CompositionError, "Type resolver config may only specify a type name for abstract resolvers."
543
556
  elsif !resolver_type.possible_types.find { _1.graphql_name == config.type_name }
544
557
  raise CompositionError, "Type `#{config.type_name}` is not a possible return type for query `#{field_name}`."
545
558
  end
@@ -548,7 +561,7 @@ module GraphQL
548
561
  resolver_type.graphql_name
549
562
  end
550
563
 
551
- key = Resolver.parse_key_with_types(
564
+ key = TypeResolver.parse_key_with_types(
552
565
  config.key,
553
566
  @subgraph_types_by_name_and_location[resolver_type_name],
554
567
  )
@@ -568,11 +581,11 @@ module GraphQL
568
581
  "#{argument.graphql_name}: $.#{key.primitive_name}"
569
582
  end
570
583
 
571
- arguments = Resolver.parse_arguments_with_field(arguments_format, subgraph_field)
584
+ arguments = TypeResolver.parse_arguments_with_field(arguments_format, subgraph_field)
572
585
  arguments.each { _1.verify_key(key) }
573
586
 
574
587
  @resolver_map[resolver_type_name] ||= []
575
- @resolver_map[resolver_type_name] << Resolver.new(
588
+ @resolver_map[resolver_type_name] << TypeResolver.new(
576
589
  location: location,
577
590
  type_name: resolver_type_name,
578
591
  field: subgraph_field.name,
@@ -588,7 +601,7 @@ module GraphQL
588
601
  # @!scope class
589
602
  # @!visibility private
590
603
  def select_root_field_locations(schema)
591
- [schema.query, schema.mutation].tap(&:compact!).each do |root_type|
604
+ [schema.query, schema.mutation, schema.subscription].tap(&:compact!).each do |root_type|
592
605
  root_type.fields.each do |root_field_name, root_field|
593
606
  root_field_locations = @field_map[root_type.graphql_name][root_field_name]
594
607
  next unless root_field_locations.length > 1
@@ -607,15 +620,18 @@ module GraphQL
607
620
 
608
621
  # @!scope class
609
622
  # @!visibility private
610
- def expand_abstract_resolvers(schema)
623
+ def expand_abstract_resolvers(composed_schema, schemas_by_location)
611
624
  @resolver_map.keys.each do |type_name|
612
- resolver_type = schema.types[type_name]
613
- next unless resolver_type.kind.abstract?
625
+ next unless composed_schema.get_type(type_name).kind.abstract?
614
626
 
615
- expanded_types = Util.expand_abstract_type(schema, resolver_type)
616
- expanded_types.select { @subgraph_types_by_name_and_location[_1.graphql_name].length > 1 }.each do |expanded_type|
617
- @resolver_map[expanded_type.graphql_name] ||= []
618
- @resolver_map[expanded_type.graphql_name].push(*@resolver_map[type_name])
627
+ @resolver_map[type_name].each do |resolver|
628
+ abstract_type = @subgraph_types_by_name_and_location[type_name][resolver.location]
629
+ expanded_types = Util.expand_abstract_type(schemas_by_location[resolver.location], abstract_type)
630
+
631
+ expanded_types.select { @subgraph_types_by_name_and_location[_1.graphql_name].length > 1 }.each do |impl_type|
632
+ @resolver_map[impl_type.graphql_name] ||= []
633
+ @resolver_map[impl_type.graphql_name].push(resolver)
634
+ end
619
635
  end
620
636
  end
621
637
  end
@@ -660,16 +676,6 @@ module GraphQL
660
676
  memo[enum_name] << :write
661
677
  end
662
678
  end
663
-
664
- private
665
-
666
- def reset!
667
- @field_map = {}
668
- @resolver_map = {}
669
- @mapped_type_names = {}
670
- @subgraph_directives_by_name_and_location = nil
671
- @schema_directives = nil
672
- end
673
679
  end
674
680
  end
675
681
  end
@@ -23,8 +23,8 @@ module GraphQL::Stitching
23
23
  def resolve_object_scope(raw_object, parent_type, selections, typename = nil)
24
24
  return nil if raw_object.nil?
25
25
 
26
- typename ||= raw_object[Resolver::TYPENAME_EXPORT_NODE.alias]
27
- raw_object.reject! { |key, _v| Resolver.export_key?(key) }
26
+ typename ||= raw_object[TypeResolver::TYPENAME_EXPORT_NODE.alias]
27
+ raw_object.reject! { |key, _v| TypeResolver.export_key?(key) }
28
28
 
29
29
  selections.each do |node|
30
30
  case node
@@ -105,7 +105,7 @@ module GraphQL::Stitching
105
105
  is_root = parent_type == @root_type
106
106
 
107
107
  case node.name
108
- when "__typename"
108
+ when TYPENAME
109
109
  yield(is_root)
110
110
  true
111
111
  when "__schema", "__type"
@@ -2,7 +2,7 @@
2
2
 
3
3
  module GraphQL::Stitching
4
4
  class Executor
5
- class ResolverSource < GraphQL::Dataloader::Source
5
+ class TypeResolverSource < GraphQL::Dataloader::Source
6
6
  def initialize(executor, location)
7
7
  @executor = executor
8
8
  @location = location
@@ -17,7 +17,7 @@ module GraphQL::Stitching
17
17
 
18
18
  if op.if_type
19
19
  # operations planned around unused fragment conditions should not trigger requests
20
- origin_set.select! { _1[Resolver::TYPENAME_EXPORT_NODE.alias] == op.if_type }
20
+ origin_set.select! { _1[TypeResolver::TYPENAME_EXPORT_NODE.alias] == op.if_type }
21
21
  end
22
22
 
23
23
  memo[op] = origin_set if origin_set.any?
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require_relative "./executor/resolver_source"
5
- require_relative "./executor/root_source"
6
- require_relative "./executor/shaper"
4
+ require_relative "executor/root_source"
5
+ require_relative "executor/type_resolver_source"
6
+ require_relative "executor/shaper"
7
7
 
8
8
  module GraphQL
9
9
  module Stitching
10
+ # Executor handles executing upon a planned request.
11
+ # All planned steps are initiated, their results merged,
12
+ # and loaded keys are collected for batching subsequent steps.
13
+ # Final execution results are then shaped to match the request selection.
10
14
  class Executor
11
15
  # @return [Request] the stitching request to execute.
12
16
  attr_reader :request
@@ -20,10 +24,14 @@ module GraphQL
20
24
  # @return [Integer] tally of queries performed while executing.
21
25
  attr_accessor :query_count
22
26
 
23
- def initialize(request, nonblocking: false)
27
+ # Builds a new executor.
28
+ # @param request [Request] the stitching request to execute.
29
+ # @param nonblocking [Boolean] specifies if the dataloader should use async concurrency.
30
+ def initialize(request, data: {}, errors: [], after: 0, nonblocking: false)
24
31
  @request = request
25
- @data = {}
26
- @errors = []
32
+ @data = data
33
+ @errors = errors
34
+ @after = after
27
35
  @query_count = 0
28
36
  @exec_cycles = 0
29
37
  @dataloader = GraphQL::Dataloader.new(nonblocking: nonblocking)
@@ -40,13 +48,13 @@ module GraphQL
40
48
  if @errors.length > 0
41
49
  result["errors"] = @errors
42
50
  end
43
-
44
- result
51
+
52
+ GraphQL::Query::Result.new(query: @request, values: result)
45
53
  end
46
54
 
47
55
  private
48
56
 
49
- def exec!(next_steps = [0])
57
+ def exec!(next_steps = [@after])
50
58
  if @exec_cycles > @request.plan.ops.length
51
59
  # sanity check... if we've exceeded queue size, then something went wrong.
52
60
  raise StitchingError, "Too many execution requests attempted."
@@ -58,8 +66,8 @@ module GraphQL
58
66
  .select { next_steps.include?(_1.after) }
59
67
  .group_by { [_1.location, _1.resolver.nil?] }
60
68
  .map do |(location, root_source), ops|
61
- source_type = root_source ? RootSource : ResolverSource
62
- @dataloader.with(source_type, self, location).request_all(ops)
69
+ source_class = root_source ? RootSource : TypeResolverSource
70
+ @dataloader.with(source_class, self, location).request_all(ops)
63
71
  end
64
72
 
65
73
  tasks.each(&method(:exec_task))
@@ -6,6 +6,9 @@ require "json"
6
6
 
7
7
  module GraphQL
8
8
  module Stitching
9
+ # HttpExecutable provides an out-of-the-box convenience for sending
10
+ # HTTP post requests to a remote location, or a base class
11
+ # for other implementations with GraphQL multipart uploads.
9
12
  class HttpExecutable
10
13
  # Builds a new executable for proxying subgraph requests via HTTP.
11
14
  # @param url [String] the url of the remote location to proxy.
@@ -2,7 +2,7 @@
2
2
 
3
3
  module GraphQL
4
4
  module Stitching
5
- # Immutable (in theory) structures representing a query plan.
5
+ # Immutable-ish structures representing a query plan.
6
6
  # May serialize to/from JSON.
7
7
  class Plan
8
8
  Op = Struct.new(
@@ -16,7 +16,7 @@ module GraphQL::Stitching
16
16
  parent_type:,
17
17
  index:,
18
18
  after: nil,
19
- operation_type: "query",
19
+ operation_type: QUERY_OP,
20
20
  selections: [],
21
21
  variables: {},
22
22
  path: [],
@@ -1,14 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "./planner/step"
3
+ require_relative "planner/step"
4
4
 
5
5
  module GraphQL
6
6
  module Stitching
7
+ # Planner partitions request selections by best-fit graph locations,
8
+ # and provides a query plan with sequential execution steps.
7
9
  class Planner
8
10
  SUPERGRAPH_LOCATIONS = [Supergraph::SUPERGRAPH_LOCATION].freeze
9
- TYPENAME = "__typename"
10
- QUERY_OP = "query"
11
- MUTATION_OP = "mutation"
12
11
  ROOT_INDEX = 0
13
12
 
14
13
  def initialize(request)
@@ -36,6 +35,7 @@ module GraphQL
36
35
  # A) Group all root selections by their preferred entrypoint locations.
37
36
  # A.1) Group query fields by location for parallel execution.
38
37
  # A.2) Partition mutation fields by consecutive location for serial execution.
38
+ # A.3) Permit exactly one subscription field.
39
39
  #
40
40
  # B) Extract contiguous selections for each entrypoint location.
41
41
  # B.1) Selections on interface types that do not belong to the interface at the
@@ -76,7 +76,7 @@ module GraphQL
76
76
  resolver: nil
77
77
  )
78
78
  # coalesce repeat parameters into a single entrypoint
79
- entrypoint = String.new("#{parent_index}/#{location}/#{parent_type.graphql_name}/#{resolver&.key&.to_definition}")
79
+ entrypoint = String.new("#{parent_index}/#{location}/#{parent_type.graphql_name}/#{resolver&.key&.to_definition}/#")
80
80
  path.each { entrypoint << "/#{_1}" }
81
81
 
82
82
  step = @steps_by_entrypoint[entrypoint]
@@ -108,11 +108,11 @@ module GraphQL
108
108
 
109
109
  # A) Group all root selections by their preferred entrypoint locations.
110
110
  def build_root_entrypoints
111
+ parent_type = @supergraph.schema.root_type_for_operation(@request.operation.operation_type)
112
+
111
113
  case @request.operation.operation_type
112
114
  when QUERY_OP
113
115
  # A.1) Group query fields by location for parallel execution.
114
- parent_type = @supergraph.schema.query
115
-
116
116
  selections_by_location = {}
117
117
  each_field_in_scope(parent_type, @request.operation.selections) do |node|
118
118
  locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name] || SUPERGRAPH_LOCATIONS
@@ -126,13 +126,12 @@ module GraphQL
126
126
  parent_index: ROOT_INDEX,
127
127
  parent_type: parent_type,
128
128
  selections: selections,
129
+ operation_type: QUERY_OP,
129
130
  )
130
131
  end
131
132
 
132
133
  when MUTATION_OP
133
134
  # A.2) Partition mutation fields by consecutive location for serial execution.
134
- parent_type = @supergraph.schema.mutation
135
-
136
135
  partitions = []
137
136
  each_field_in_scope(parent_type, @request.operation.selections) do |node|
138
137
  next_location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].first
@@ -154,6 +153,21 @@ module GraphQL
154
153
  ).index
155
154
  end
156
155
 
156
+ when SUBSCRIPTION_OP
157
+ # A.3) Permit exactly one subscription field.
158
+ each_field_in_scope(parent_type, @request.operation.selections) do |node|
159
+ raise StitchingError, "Too many root fields for subscription." unless @steps_by_entrypoint.empty?
160
+
161
+ locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name] || SUPERGRAPH_LOCATIONS
162
+ add_step(
163
+ location: locations.first,
164
+ parent_index: ROOT_INDEX,
165
+ parent_type: parent_type,
166
+ selections: [node],
167
+ operation_type: SUBSCRIPTION_OP,
168
+ )
169
+ end
170
+
157
171
  else
158
172
  raise StitchingError, "Invalid operation type."
159
173
  end
@@ -201,8 +215,8 @@ module GraphQL
201
215
  input_selections.each do |node|
202
216
  case node
203
217
  when GraphQL::Language::Nodes::Field
204
- if node.alias&.start_with?(Resolver::EXPORT_PREFIX)
205
- raise StitchingError, %(Alias "#{node.alias}" is not allowed because "#{Resolver::EXPORT_PREFIX}" is a reserved prefix.)
218
+ if node.alias&.start_with?(TypeResolver::EXPORT_PREFIX)
219
+ raise StitchingError, %(Alias "#{node.alias}" is not allowed because "#{TypeResolver::EXPORT_PREFIX}" is a reserved prefix.)
206
220
  elsif node.name == TYPENAME
207
221
  locale_selections << node
208
222
  next
@@ -263,8 +277,8 @@ module GraphQL
263
277
 
264
278
  # B.4) Add a `__typename` export to abstracts and types that implement
265
279
  # fragments so that resolved type information is available during execution.
266
- if requires_typename && !locale_selections.include?(Resolver::TYPENAME_EXPORT_NODE)
267
- locale_selections << Resolver::TYPENAME_EXPORT_NODE
280
+ if requires_typename && !locale_selections.include?(TypeResolver::TYPENAME_EXPORT_NODE)
281
+ locale_selections << TypeResolver::TYPENAME_EXPORT_NODE
268
282
  end
269
283
 
270
284
  if remote_selections
@@ -280,8 +294,8 @@ module GraphQL
280
294
  # E.1) Add the key of each resolver query into the prior location's selection set.
281
295
  parent_selections.push(*resolver.key.export_nodes) if resolver.key
282
296
  parent_selections.uniq! do |node|
283
- export_node = node.is_a?(GraphQL::Language::Nodes::Field) && Resolver.export_key?(node.alias)
284
- export_node ? node.alias : node
297
+ export_node = node.is_a?(GraphQL::Language::Nodes::Field) && TypeResolver.export_key?(node.alias)
298
+ export_node ? node.alias : node.object_id
285
299
  end
286
300
 
287
301
  # E.2) Add a planner step for each new entrypoint location.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module GraphQL
4
- module Stitching
3
+ module GraphQL::Stitching
4
+ class Request
5
5
  # Faster implementation of an AST visitor for prerendering
6
6
  # @skip and @include conditional directives into a document.
7
7
  # This avoids unnecessary planning steps, and prepares result shaping.
@@ -40,7 +40,7 @@ module GraphQL
40
40
  end
41
41
 
42
42
  if filtered_selections.none?
43
- filtered_selections << Resolver::TYPENAME_EXPORT_NODE
43
+ filtered_selections << TypeResolver::TYPENAME_EXPORT_NODE
44
44
  end
45
45
 
46
46
  if changed
@@ -1,9 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "request/skip_include"
4
+
3
5
  module GraphQL
4
6
  module Stitching
7
+ # Request combines a supergraph, GraphQL document, variables,
8
+ # variable/fragment definitions, and the selected operation.
9
+ # It provides the lifecycle of validating, preparing,
10
+ # planning, and executing upon these inputs.
5
11
  class Request
6
- SUPPORTED_OPERATIONS = ["query", "mutation"].freeze
7
12
  SKIP_INCLUDE_DIRECTIVE = /@(?:skip|include)/
8
13
 
9
14
  # @return [Supergraph] supergraph instance that resolves the request.
@@ -70,12 +75,12 @@ module GraphQL
70
75
 
71
76
  # @return [String] a digest of the original document string. Generally faster but less consistent.
72
77
  def digest
73
- @digest ||= Digest::SHA2.hexdigest(string)
78
+ @digest ||= Stitching.digest.call("#{Stitching::VERSION}/#{string}")
74
79
  end
75
80
 
76
81
  # @return [String] a digest of the normalized document string. Slower but more consistent.
77
82
  def normalized_digest
78
- @normalized_digest ||= Digest::SHA2.hexdigest(normalized_string)
83
+ @normalized_digest ||= Stitching.digest.call("#{Stitching::VERSION}/#{normalized_string}")
79
84
  end
80
85
 
81
86
  # @return [GraphQL::Language::Nodes::OperationDefinition] The selected root operation for the request.
@@ -83,7 +88,6 @@ module GraphQL
83
88
  @operation ||= begin
84
89
  operation_defs = @document.definitions.select do |d|
85
90
  next unless d.is_a?(GraphQL::Language::Nodes::OperationDefinition)
86
- next unless SUPPORTED_OPERATIONS.include?(d.operation_type)
87
91
  @operation_name ? d.name == @operation_name : true
88
92
  end
89
93
 
@@ -97,6 +101,21 @@ module GraphQL
97
101
  end
98
102
  end
99
103
 
104
+ # @return [Boolean] true if operation type is a query
105
+ def query?
106
+ operation.operation_type == QUERY_OP
107
+ end
108
+
109
+ # @return [Boolean] true if operation type is a mutation
110
+ def mutation?
111
+ operation.operation_type == MUTATION_OP
112
+ end
113
+
114
+ # @return [Boolean] true if operation type is a subscription
115
+ def subscription?
116
+ operation.operation_type == SUBSCRIPTION_OP
117
+ end
118
+
100
119
  # @return [String] A string of directives applied to the root operation. These are passed through in all subgraph requests.
101
120
  def operation_directives
102
121
  @operation_directives ||= if operation.directives.any?
@@ -162,7 +181,7 @@ module GraphQL
162
181
  raise StitchingError, "Plan must be a `GraphQL::Stitching::Plan`." unless new_plan.is_a?(Plan)
163
182
  @plan = new_plan
164
183
  else
165
- @plan ||= GraphQL::Stitching::Planner.new(self).perform
184
+ @plan ||= Planner.new(self).perform
166
185
  end
167
186
  end
168
187
 
@@ -170,7 +189,26 @@ module GraphQL
170
189
  # @param raw [Boolean] specifies the result should be unshaped without pruning or null bubbling. Useful for debugging.
171
190
  # @return [Hash] the rendered GraphQL response with "data" and "errors" sections.
172
191
  def execute(raw: false)
173
- GraphQL::Stitching::Executor.new(self).perform(raw: raw)
192
+ add_subscription_update_handler if subscription?
193
+ Executor.new(self).perform(raw: raw)
194
+ end
195
+
196
+ private
197
+
198
+ # Adds a handler into context for enriching subscription updates with stitched data
199
+ def add_subscription_update_handler
200
+ request = self
201
+ @context[:stitch_subscription_update] = -> (result) {
202
+ stitched_result = Executor.new(
203
+ request,
204
+ data: result.to_h["data"] || {},
205
+ errors: result.to_h["errors"] || [],
206
+ after: request.plan.ops.first.step,
207
+ ).perform
208
+
209
+ result.to_h.merge!(stitched_result.to_h)
210
+ result
211
+ }
174
212
  end
175
213
  end
176
214
  end
@@ -32,7 +32,7 @@ module GraphQL::Stitching
32
32
  end
33
33
 
34
34
  key_definitions = locations_by_key.each_with_object({}) do |(key, locations), memo|
35
- memo[key] = Resolver.parse_key(key, locations)
35
+ memo[key] = TypeResolver.parse_key(key, locations)
36
36
  end
37
37
 
38
38
  # Collect/build resolver definitions for each type
@@ -41,13 +41,13 @@ module GraphQL::Stitching
41
41
 
42
42
  kwargs = directive.arguments.keyword_arguments
43
43
  resolver_map[type_name] ||= []
44
- resolver_map[type_name] << Resolver.new(
44
+ resolver_map[type_name] << TypeResolver.new(
45
45
  location: kwargs[:location],
46
46
  type_name: kwargs.fetch(:type_name, type_name),
47
47
  field: kwargs[:field],
48
48
  list: kwargs[:list] || false,
49
49
  key: key_definitions[kwargs[:key]],
50
- arguments: Resolver.parse_arguments_with_type_defs(kwargs[:arguments], kwargs[:argument_types]),
50
+ arguments: TypeResolver.parse_arguments_with_type_defs(kwargs[:arguments], kwargs[:argument_types]),
51
51
  )
52
52
  end
53
53
 
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "./supergraph/to_definition"
3
+ require_relative "supergraph/to_definition"
4
4
 
5
5
  module GraphQL
6
6
  module Stitching
7
+ # Supergraph is the singuar representation of a stitched graph.
8
+ # It provides the combined GraphQL schema and delegation maps
9
+ # used to route selections across subgraph locations.
7
10
  class Supergraph
8
11
  SUPERGRAPH_LOCATION = "__super"
9
12
 
@@ -100,7 +103,7 @@ module GraphQL
100
103
  executable.execute(
101
104
  query: source,
102
105
  variables: variables,
103
- context: request.context.frozen? ? request.context.dup : request.context,
106
+ context: request.context.to_h,
104
107
  validate: false,
105
108
  )
106
109
  elsif executable.respond_to?(:call)
@@ -163,7 +166,7 @@ module GraphQL
163
166
  if key_count.zero?
164
167
  # nested root scopes have no resolver keys and just return a location
165
168
  goal_locations.each_with_object({}) do |goal_location, memo|
166
- memo[goal_location] = [Resolver.new(location: goal_location)]
169
+ memo[goal_location] = [TypeResolver.new(location: goal_location)]
167
170
  end
168
171
 
169
172
  elsif key_count > 1