graphql 2.0.29 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -31,11 +31,6 @@ module GraphQL
31
31
  # visitor.count
32
32
  # # => 3
33
33
  class Visitor
34
- # If any hook returns this value, the {Visitor} stops visiting this
35
- # node right away
36
- # @deprecated Use `super` to continue the visit; or don't call it to halt.
37
- SKIP = :_skip
38
-
39
34
  class DeleteNode; end
40
35
 
41
36
  # When this is returned from a visitor method,
@@ -44,25 +39,13 @@ module GraphQL
44
39
 
45
40
  def initialize(document)
46
41
  @document = document
47
- @visitors = {}
48
42
  @result = nil
49
43
  end
50
44
 
51
45
  # @return [GraphQL::Language::Nodes::Document] The document with any modifications applied
52
46
  attr_reader :result
53
47
 
54
- # Get a {NodeVisitor} for `node_class`
55
- # @param node_class [Class] The node class that you want to listen to
56
- # @return [NodeVisitor]
57
- #
58
- # @example Run a hook whenever you enter a new Field
59
- # visitor[GraphQL::Language::Nodes::Field] << ->(node, parent) { p "Here's a field" }
60
- # @deprecated see `on_` methods, like {#on_field}
61
- def [](node_class)
62
- @visitors[node_class] ||= NodeVisitor.new
63
- end
64
-
65
- # Visit `document` and all children, applying hooks as you go
48
+ # Visit `document` and all children
66
49
  # @return [void]
67
50
  def visit
68
51
  # `@document` may be any kind of node:
@@ -88,7 +71,6 @@ module GraphQL
88
71
  # To customize this hook, override one of its make_visit_methods (or the base method?)
89
72
  # in your subclasses.
90
73
  #
91
- # For compatibility, it calls hook procs, too.
92
74
  # @param node [GraphQL::Language::Nodes::AbstractNode] the node being visited
93
75
  # @param parent [GraphQL::Language::Nodes::AbstractNode, nil] the previously-visited node, or `nil` if this is the root node.
94
76
  # @return [Array, nil] If there were modifications, it returns an array of new nodes, otherwise, it returns `nil`.
@@ -98,29 +80,24 @@ module GraphQL
98
80
  # by a user hook, don't want to keep visiting in that case.
99
81
  [node, parent]
100
82
  else
101
- # Run hooks if there are any
102
83
  new_node = node
103
- no_hooks = !@visitors.key?(node.class)
104
- if no_hooks || begin_visit(new_node, parent)
105
- #{
106
- if method_defined?(child_visit_method)
107
- "new_node = #{child_visit_method}(new_node)"
108
- elsif children_of_type
109
- children_of_type.map do |child_accessor, child_class|
110
- "node.#{child_accessor}.each do |child_node|
111
- new_child_and_node = #{child_class.visit_method}_with_modifications(child_node, new_node)
112
- # Reassign `node` in case the child hook makes a modification
113
- if new_child_and_node.is_a?(Array)
114
- new_node = new_child_and_node[1]
115
- end
116
- end"
117
- end.join("\n")
118
- else
119
- ""
120
- end
121
- }
122
- end
123
- end_visit(new_node, parent) unless no_hooks
84
+ #{
85
+ if method_defined?(child_visit_method)
86
+ "new_node = #{child_visit_method}(new_node)"
87
+ elsif children_of_type
88
+ children_of_type.map do |child_accessor, child_class|
89
+ "node.#{child_accessor}.each do |child_node|
90
+ new_child_and_node = #{child_class.visit_method}_with_modifications(child_node, new_node)
91
+ # Reassign `node` in case the child hook makes a modification
92
+ if new_child_and_node.is_a?(Array)
93
+ new_node = new_child_and_node[1]
94
+ end
95
+ end"
96
+ end.join("\n")
97
+ else
98
+ ""
99
+ end
100
+ }
124
101
 
125
102
  if new_node.equal?(node)
126
103
  [node, parent]
@@ -305,46 +282,6 @@ module GraphQL
305
282
  new_node_and_new_parent
306
283
  end
307
284
  end
308
-
309
- def begin_visit(node, parent)
310
- node_visitor = self[node.class]
311
- self.class.apply_hooks(node_visitor.enter, node, parent)
312
- end
313
-
314
- # Should global `leave` visitors come first or last?
315
- def end_visit(node, parent)
316
- node_visitor = self[node.class]
317
- self.class.apply_hooks(node_visitor.leave, node, parent)
318
- end
319
-
320
- # If one of the visitors returns SKIP, stop visiting this node
321
- def self.apply_hooks(hooks, node, parent)
322
- hooks.each do |proc|
323
- return false if proc.call(node, parent) == SKIP
324
- end
325
- true
326
- end
327
-
328
- # Collect `enter` and `leave` hooks for classes in {GraphQL::Language::Nodes}
329
- #
330
- # Access {NodeVisitor}s via {GraphQL::Language::Visitor#[]}
331
- class NodeVisitor
332
- # @return [Array<Proc>] Hooks to call when entering a node of this type
333
- attr_reader :enter
334
- # @return [Array<Proc>] Hooks to call when leaving a node of this type
335
- attr_reader :leave
336
-
337
- def initialize
338
- @enter = []
339
- @leave = []
340
- end
341
-
342
- # Shorthand to add a hook to the {#enter} array
343
- # @param hook [Proc] A hook to add
344
- def <<(hook)
345
- enter << hook
346
- end
347
- end
348
285
  end
349
286
  end
350
287
  end
@@ -19,7 +19,14 @@ module GraphQL
19
19
  attr_reader :items
20
20
 
21
21
  # @return [GraphQL::Query::Context]
22
- attr_accessor :context
22
+ attr_reader :context
23
+
24
+ def context=(new_ctx)
25
+ current_runtime_state = Thread.current[:__graphql_runtime_info]
26
+ query_runtime_state = current_runtime_state[new_ctx.query]
27
+ @was_authorized_by_scope_items = query_runtime_state.was_authorized_by_scope_items
28
+ @context = new_ctx
29
+ end
23
30
 
24
31
  # @return [Object] the object this collection belongs to
25
32
  attr_accessor :parent
@@ -83,6 +90,17 @@ module GraphQL
83
90
  else
84
91
  default_page_size
85
92
  end
93
+ @was_authorized_by_scope_items = if @context
94
+ current_runtime_state = Thread.current[:__graphql_runtime_info]
95
+ query_runtime_state = current_runtime_state[@context.query]
96
+ query_runtime_state.was_authorized_by_scope_items
97
+ else
98
+ nil
99
+ end
100
+ end
101
+
102
+ def was_authorized_by_scope_items?
103
+ @was_authorized_by_scope_items
86
104
  end
87
105
 
88
106
  def max_page_size=(new_value)
@@ -247,6 +265,10 @@ module GraphQL
247
265
  def cursor
248
266
  @cursor ||= @connection.cursor_for(@node)
249
267
  end
268
+
269
+ def was_authorized_by_scope_items?
270
+ @connection.was_authorized_by_scope_items?
271
+ end
250
272
  end
251
273
  end
252
274
  end
data/lib/graphql/query.rb CHANGED
@@ -95,15 +95,10 @@ module GraphQL
95
95
  # @param root_value [Object] the object used to resolve fields on the root type
96
96
  # @param max_depth [Numeric] the maximum number of nested selections allowed for this query (falls back to schema-level value)
97
97
  # @param max_complexity [Numeric] the maximum field complexity for this query (falls back to schema-level value)
98
- # @param except [<#call(schema_member, context)>] If provided, objects will be hidden from the schema when `.call(schema_member, context)` returns truthy
99
- # @param only [<#call(schema_member, context)>] If provided, objects will be hidden from the schema when `.call(schema_member, context)` returns false
100
- def initialize(schema, query_string = nil, query: nil, document: nil, context: nil, variables: nil, validate: true, static_validator: nil, subscription_topic: nil, operation_name: nil, root_value: nil, max_depth: schema.max_depth, max_complexity: schema.max_complexity, except: nil, only: nil, warden: nil)
98
+ def initialize(schema, query_string = nil, query: nil, document: nil, context: nil, variables: nil, validate: true, static_validator: nil, subscription_topic: nil, operation_name: nil, root_value: nil, max_depth: schema.max_depth, max_complexity: schema.max_complexity, warden: nil)
101
99
  # Even if `variables: nil` is passed, use an empty hash for simpler logic
102
100
  variables ||= {}
103
101
  @schema = schema
104
- if only || except
105
- merge_filters(except: except, only: only)
106
- end
107
102
  @context = schema.context_class.new(query: self, object: root_value, values: context)
108
103
  @warden = warden
109
104
  @subscription_topic = subscription_topic
@@ -129,7 +124,6 @@ module GraphQL
129
124
  raise ArgumentError, "context[:tracers] are not supported without `trace_with(GraphQL::Tracing::CallLegacyTracers)` in the schema configuration, please add it."
130
125
  end
131
126
 
132
-
133
127
  @analysis_errors = []
134
128
  if variables.is_a?(String)
135
129
  raise ArgumentError, "Query variables should be a Hash, not a String. Try JSON.parse to prepare variables."
@@ -354,17 +348,6 @@ module GraphQL
354
348
  with_prepared_ast { @query }
355
349
  end
356
350
 
357
- # @return [void]
358
- def merge_filters(only: nil, except: nil)
359
- if @prepared_ast
360
- raise "Can't add filters after preparing the query"
361
- else
362
- @filter ||= @schema.default_filter
363
- @filter = @filter.merge(only: only, except: except)
364
- end
365
- nil
366
- end
367
-
368
351
  def subscription?
369
352
  with_prepared_ast { @subscription }
370
353
  end
@@ -400,7 +383,7 @@ module GraphQL
400
383
 
401
384
  def prepare_ast
402
385
  @prepared_ast = true
403
- @warden ||= @schema.warden_class.new(@filter, schema: @schema, context: @context)
386
+ @warden ||= @schema.warden_class.new(schema: @schema, context: @context)
404
387
  parse_error = nil
405
388
  @document ||= begin
406
389
  if query_string
@@ -9,8 +9,7 @@ module GraphQL
9
9
  # By default, schemas are looked up by name as constants using `schema_name:`.
10
10
  # You can provide a `load_schema` function to return your schema another way.
11
11
  #
12
- # `load_context:`, `only:` and `except:` are supported so that
13
- # you can keep an eye on how filters affect your schema.
12
+ # Use `load_context:` and `visible?` to dump schemas under certain visibility constraints.
14
13
  #
15
14
  # @example Dump a Schema to .graphql + .json files
16
15
  # require "graphql/rake_task"
@@ -36,8 +35,6 @@ module GraphQL
36
35
  schema_name: nil,
37
36
  load_schema: ->(task) { Object.const_get(task.schema_name) },
38
37
  load_context: ->(task) { {} },
39
- only: nil,
40
- except: nil,
41
38
  directory: ".",
42
39
  idl_outfile: "schema.graphql",
43
40
  json_outfile: "schema.json",
@@ -68,12 +65,6 @@ module GraphQL
68
65
  # @return [<#call(task)>] A callable for loading the query context
69
66
  attr_accessor :load_context
70
67
 
71
- # @return [<#call(member, ctx)>, nil] A filter for this task
72
- attr_accessor :only
73
-
74
- # @return [<#call(member, ctx)>, nil] A filter for this task
75
- attr_accessor :except
76
-
77
68
  # @return [String] target for IDL task
78
69
  attr_accessor :idl_outfile
79
70
 
@@ -117,10 +108,10 @@ module GraphQL
117
108
  include_is_repeatable: include_is_repeatable,
118
109
  include_specified_by_url: include_specified_by_url,
119
110
  include_schema_description: include_schema_description,
120
- only: @only, except: @except, context: context
111
+ context: context
121
112
  )
122
113
  when :to_definition
123
- schema.to_definition(only: @only, except: @except, context: context)
114
+ schema.to_definition(context: context)
124
115
  else
125
116
  raise ArgumentError, "Unexpected schema dump method: #{method_name.inspect}"
126
117
  end
@@ -10,7 +10,13 @@ module GraphQL
10
10
  else
11
11
  ret_type = @field.type.unwrap
12
12
  if ret_type.respond_to?(:scope_items)
13
- ret_type.scope_items(value, context)
13
+ scoped_items = ret_type.scope_items(value, context)
14
+ if !scoped_items.equal?(value) && !ret_type.reauthorize_scoped_objects
15
+ current_runtime_state = Thread.current[:__graphql_runtime_info]
16
+ query_runtime_state = current_runtime_state[context.query]
17
+ query_runtime_state.was_authorized_by_scope_items = true
18
+ end
19
+ scoped_items
14
20
  else
15
21
  value
16
22
  end
@@ -69,11 +69,7 @@ module GraphQL
69
69
  end
70
70
  elsif child_class < GraphQL::Schema::Object
71
71
  # This is being included into an object type, make sure it's using `implements(...)`
72
- backtrace_line = caller_locations(0, 10).find do |location|
73
- location.base_label == "implements" &&
74
- location.path.end_with?("schema/member/has_interfaces.rb")
75
- end
76
-
72
+ backtrace_line = caller(0, 10).find { |line| line.include?("schema/member/has_interfaces.rb") && line.include?("in `implements'")}
77
73
  if !backtrace_line
78
74
  raise "Attach interfaces using `implements(#{self})`, not `include(#{self})`"
79
75
  end
@@ -15,6 +15,25 @@ module GraphQL
15
15
  def scope_items(items, context)
16
16
  items
17
17
  end
18
+
19
+ def reauthorize_scoped_objects(new_value = nil)
20
+ if new_value.nil?
21
+ if @reauthorize_scoped_objects != nil
22
+ @reauthorize_scoped_objects
23
+ else
24
+ find_inherited_value(:reauthorize_scoped_objects, nil)
25
+ end
26
+ else
27
+ @reauthorize_scoped_objects = new_value
28
+ end
29
+ end
30
+
31
+ def inherited(subclass)
32
+ super
33
+ subclass.class_eval do
34
+ @reauthorize_scoped_objects = nil
35
+ end
36
+ end
18
37
  end
19
38
  end
20
39
  end
@@ -30,6 +30,10 @@ module GraphQL
30
30
  # @see authorized_new to make instances
31
31
  protected :new
32
32
 
33
+ def wrap_scoped(object, context)
34
+ scoped_new(object, context)
35
+ end
36
+
33
37
  # This is called by the runtime to return an object to call methods on.
34
38
  def wrap(object, context)
35
39
  authorized_new(object, context)
@@ -91,6 +95,10 @@ module GraphQL
91
95
  end
92
96
  end
93
97
  end
98
+
99
+ def scoped_new(object, context)
100
+ self.new(object, context)
101
+ end
94
102
  end
95
103
 
96
104
  def initialize(object, context)
@@ -36,15 +36,11 @@ module GraphQL
36
36
 
37
37
  # @param schema [GraphQL::Schema]
38
38
  # @param context [Hash]
39
- # @param only [<#call(member, ctx)>]
40
- # @param except [<#call(member, ctx)>]
41
39
  # @param introspection [Boolean] Should include the introspection types in the string?
42
- def initialize(schema, context: nil, only: nil, except: nil, introspection: false)
40
+ def initialize(schema, context: nil, introspection: false)
43
41
  @document_from_schema = GraphQL::Language::DocumentFromSchemaDefinition.new(
44
42
  schema,
45
43
  context: context,
46
- only: only,
47
- except: except,
48
44
  include_introspection_types: introspection,
49
45
  )
50
46
 
@@ -61,7 +57,12 @@ module GraphQL
61
57
  false
62
58
  end
63
59
  end
64
- schema = Class.new(GraphQL::Schema) { query(query_root) }
60
+ schema = Class.new(GraphQL::Schema) {
61
+ query(query_root)
62
+ def self.visible?(member, _ctx)
63
+ member.graphql_name != "Root"
64
+ end
65
+ }
65
66
 
66
67
  introspection_schema_ast = GraphQL::Language::DocumentFromSchemaDefinition.new(
67
68
  schema,
@@ -94,7 +95,7 @@ module GraphQL
94
95
 
95
96
  class IntrospectionPrinter < GraphQL::Language::Printer
96
97
  def print_schema_definition(schema)
97
- "schema {\n query: Root\n}"
98
+ print_string("schema {\n query: Root\n}")
98
99
  end
99
100
  end
100
101
  end
@@ -28,14 +28,19 @@ module GraphQL
28
28
  def resolve_with_support(**args)
29
29
  result = nil
30
30
  unsubscribed = true
31
- catch :graphql_subscription_unsubscribed do
31
+ unsubscribed_result = catch :graphql_subscription_unsubscribed do
32
32
  result = super
33
33
  unsubscribed = false
34
34
  end
35
35
 
36
36
 
37
37
  if unsubscribed
38
- context.skip
38
+ if unsubscribed_result
39
+ context.namespace(:subscriptions)[:final_update] = true
40
+ unsubscribed_result
41
+ else
42
+ context.skip
43
+ end
39
44
  else
40
45
  result
41
46
  end
@@ -94,9 +99,11 @@ module GraphQL
94
99
  end
95
100
 
96
101
  # Call this to halt execution and remove this subscription from the system
97
- def unsubscribe
102
+ # @param update_value [Object] if present, deliver this update before unsubscribing
103
+ # @return [void]
104
+ def unsubscribe(update_value = nil)
98
105
  context.namespace(:subscriptions)[:unsubscribed] = true
99
- throw :graphql_subscription_unsubscribed
106
+ throw :graphql_subscription_unsubscribed, update_value
100
107
  end
101
108
 
102
109
  READING_SCOPE = ::Object.new
@@ -4,37 +4,12 @@ require 'set'
4
4
 
5
5
  module GraphQL
6
6
  class Schema
7
- # Restrict access to a {GraphQL::Schema} with a user-defined filter.
7
+ # Restrict access to a {GraphQL::Schema} with a user-defined `visible?` implementations.
8
8
  #
9
9
  # When validating and executing a query, all access to schema members
10
10
  # should go through a warden. If you access the schema directly,
11
11
  # you may show a client something that it shouldn't be allowed to see.
12
12
  #
13
- # @example Hiding private fields
14
- # private_members = -> (member, ctx) { member.metadata[:private] }
15
- # result = Schema.execute(query_string, except: private_members)
16
- #
17
- # @example Custom filter implementation
18
- # # It must respond to `#call(member)`.
19
- # class MissingRequiredFlags
20
- # def initialize(user)
21
- # @user = user
22
- # end
23
- #
24
- # # Return `false` if any required flags are missing
25
- # def call(member, ctx)
26
- # member.metadata[:required_flags].any? do |flag|
27
- # !@user.has_flag?(flag)
28
- # end
29
- # end
30
- # end
31
- #
32
- # # Then, use the custom filter in query:
33
- # missing_required_flags = MissingRequiredFlags.new(current_user)
34
- #
35
- # # This query can only access members which match the user's flags
36
- # result = Schema.execute(query_string, except: missing_required_flags)
37
- #
38
13
  # @api private
39
14
  class Warden
40
15
  def self.from_context(context)
@@ -114,22 +89,16 @@ module GraphQL
114
89
  def interfaces(obj_type); obj_type.interfaces; end
115
90
  end
116
91
 
117
- # @param filter [<#call(member)>] Objects are hidden when `.call(member, ctx)` returns true
118
92
  # @param context [GraphQL::Query::Context]
119
93
  # @param schema [GraphQL::Schema]
120
- def initialize(filter = nil, context:, schema:)
94
+ def initialize(context:, schema:)
121
95
  @schema = schema
122
96
  # Cache these to avoid repeated hits to the inheritance chain when one isn't present
123
97
  @query = @schema.query
124
98
  @mutation = @schema.mutation
125
99
  @subscription = @schema.subscription
126
100
  @context = context
127
- @visibility_cache = if filter
128
- read_through { |m| filter.call(m, context) }
129
- else
130
- read_through { |m| schema.visible?(m, context) }
131
- end
132
-
101
+ @visibility_cache = read_through { |m| schema.visible?(m, context) }
133
102
  @visibility_cache.compare_by_identity
134
103
  # Initialize all ivars to improve object shape consistency:
135
104
  @types = @visible_types = @reachable_types = @visible_parent_fields =
@@ -222,7 +222,7 @@ module GraphQL
222
222
  # @param include_specified_by_url [Boolean] If true, scalar types' `specifiedByUrl:` will be included in the response
223
223
  # @param include_is_one_of [Boolean] If true, `isOneOf: true|false` will be included with input objects
224
224
  # @return [Hash] GraphQL result
225
- def as_json(only: nil, except: nil, context: {}, include_deprecated_args: true, include_schema_description: false, include_is_repeatable: false, include_specified_by_url: false, include_is_one_of: false)
225
+ def as_json(context: {}, include_deprecated_args: true, include_schema_description: false, include_is_repeatable: false, include_specified_by_url: false, include_is_one_of: false)
226
226
  introspection_query = Introspection.query(
227
227
  include_deprecated_args: include_deprecated_args,
228
228
  include_schema_description: include_schema_description,
@@ -231,16 +231,14 @@ module GraphQL
231
231
  include_specified_by_url: include_specified_by_url,
232
232
  )
233
233
 
234
- execute(introspection_query, only: only, except: except, context: context).to_h
234
+ execute(introspection_query, context: context).to_h
235
235
  end
236
236
 
237
237
  # Return the GraphQL IDL for the schema
238
238
  # @param context [Hash]
239
- # @param only [<#call(member, ctx)>]
240
- # @param except [<#call(member, ctx)>]
241
239
  # @return [String]
242
- def to_definition(only: nil, except: nil, context: {})
243
- GraphQL::Schema::Printer.print_schema(self, only: only, except: except, context: context)
240
+ def to_definition(context: {})
241
+ GraphQL::Schema::Printer.print_schema(self, context: context)
244
242
  end
245
243
 
246
244
  # Return the GraphQL::Language::Document IDL AST for the schema
@@ -268,20 +266,6 @@ module GraphQL
268
266
  @find_cache[path] ||= @finder.find(path)
269
267
  end
270
268
 
271
- def default_filter
272
- GraphQL::Filter.new(except: default_mask)
273
- end
274
-
275
- def default_mask(new_mask = nil)
276
- if new_mask
277
- line = caller(2, 10).find { |l| !l.include?("lib/graphql") }
278
- GraphQL::Deprecation.warn("GraphQL::Filter and Schema.mask are deprecated and will be removed in v2.1.0. Implement `visible?` on your schema members instead (https://graphql-ruby.org/authorization/visibility.html).\n #{line}")
279
- @own_default_mask = new_mask
280
- else
281
- @own_default_mask || find_inherited_value(:default_mask, Schema::NullMask)
282
- end
283
- end
284
-
285
269
  def static_validator
286
270
  GraphQL::StaticValidation::Validator.new(schema: self)
287
271
  end
@@ -8,9 +8,6 @@ module GraphQL
8
8
  # It provides access to the schema & fragments which validators may read from.
9
9
  #
10
10
  # It holds a list of errors which each validator may add to.
11
- #
12
- # It also provides limited access to the {TypeStack} instance,
13
- # which tracks state as you climb in and out of different fields.
14
11
  class ValidationContext
15
12
  extend Forwardable
16
13
 
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require "graphql/static_validation/error"
3
3
  require "graphql/static_validation/definition_dependencies"
4
- require "graphql/static_validation/type_stack"
5
4
  require "graphql/static_validation/validator"
6
5
  require "graphql/static_validation/validation_context"
7
6
  require "graphql/static_validation/validation_timeout_error"
@@ -124,7 +124,8 @@ module GraphQL
124
124
  # This subscription was re-evaluated.
125
125
  # Send it to the specific stream where this client was waiting.
126
126
  def deliver(subscription_id, result)
127
- payload = { result: result.to_h, more: true }
127
+ has_more = !result.context.namespace(:subscriptions)[:final_update]
128
+ payload = { result: result.to_h, more: has_more }
128
129
  @action_cable.server.broadcast(stream_subscription_name(subscription_id), payload)
129
130
  end
130
131
 
@@ -125,10 +125,10 @@ module GraphQL
125
125
  variables: variables,
126
126
  root_value: object,
127
127
  }
128
-
128
+
129
129
  # merge event's and query's context together
130
130
  context.merge!(event.context) unless event.context.nil? || context.nil?
131
-
131
+
132
132
  execute_options[:validate] = validate_update?(**execute_options)
133
133
  result = @schema.execute(**execute_options)
134
134
  subscriptions_context = result.context.namespace(:subscriptions)
@@ -136,11 +136,9 @@ module GraphQL
136
136
  result = nil
137
137
  end
138
138
 
139
- unsubscribed = subscriptions_context[:unsubscribed]
140
-
141
- if unsubscribed
139
+ if subscriptions_context[:unsubscribed] && !subscriptions_context[:final_update]
142
140
  # `unsubscribe` was called, clean up on our side
143
- # TODO also send `{more: false}` to client?
141
+ # The transport should also send `{more: false}` to client
144
142
  delete_subscription(subscription_id)
145
143
  result = nil
146
144
  end
@@ -164,7 +162,14 @@ module GraphQL
164
162
  res = execute_update(subscription_id, event, object)
165
163
  if !res.nil?
166
164
  deliver(subscription_id, res)
165
+
166
+ if res.context.namespace(:subscriptions)[:unsubscribed]
167
+ # `unsubscribe` was called, clean up on our side
168
+ # The transport should also send `{more: false}` to client
169
+ delete_subscription(subscription_id)
170
+ end
167
171
  end
172
+
168
173
  end
169
174
 
170
175
  # Event `event` occurred on `object`,