graphql 1.9.0.pre2 → 1.9.0.pre3
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 +4 -4
- data/lib/graphql/execution/lookahead.rb +22 -13
- data/lib/graphql/language/visitor.rb +63 -52
- data/lib/graphql/query/variable_validation_error.rb +10 -1
- data/lib/graphql/schema.rb +8 -3
- data/lib/graphql/schema/field.rb +3 -2
- data/lib/graphql/schema/mutation.rb +5 -49
- data/lib/graphql/schema/resolver.rb +1 -0
- data/lib/graphql/schema/resolver/has_payload_type.rb +65 -0
- data/lib/graphql/schema/subscription.rb +97 -0
- data/lib/graphql/subscriptions.rb +15 -2
- data/lib/graphql/subscriptions/subscription_root.rb +27 -4
- data/lib/graphql/version.rb +1 -1
- data/spec/dummy/Gemfile.lock +2 -2
- data/spec/dummy/app/channels/graphql_channel.rb +4 -4
- data/spec/dummy/log/test.log +3 -0
- data/spec/graphql/execution/lookahead_spec.rb +54 -1
- data/spec/graphql/query/executor_spec.rb +15 -13
- data/spec/graphql/query_spec.rb +12 -6
- data/spec/graphql/schema/subscription_spec.rb +416 -0
- data/spec/graphql/subscriptions_spec.rb +5 -12
- metadata +291 -287
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GraphQL
|
4
|
+
class Schema
|
5
|
+
# This class can be extended to create fields on your subscription root.
|
6
|
+
#
|
7
|
+
# It provides hooks for the different parts of the subscription lifecycle:
|
8
|
+
#
|
9
|
+
# - `#authorized?`: called before initial subscription and subsequent updates
|
10
|
+
# - `#subscribe`: called for the initial subscription
|
11
|
+
# - `#update`: called for subsequent update
|
12
|
+
#
|
13
|
+
# Also, `#unsubscribe` terminates the subscription.
|
14
|
+
class Subscription < GraphQL::Schema::Resolver
|
15
|
+
class EarlyTerminationError < StandardError
|
16
|
+
end
|
17
|
+
|
18
|
+
# Raised when `unsubscribe` is called; caught by `subscriptions.rb`
|
19
|
+
class UnsubscribedError < EarlyTerminationError
|
20
|
+
end
|
21
|
+
|
22
|
+
# Raised when `no_update` is returned; caught by `subscriptions.rb`
|
23
|
+
class NoUpdateError < EarlyTerminationError
|
24
|
+
end
|
25
|
+
extend GraphQL::Schema::Resolver::HasPayloadType
|
26
|
+
extend GraphQL::Schema::Member::HasFields
|
27
|
+
|
28
|
+
# The generated payload type is required; If there's no payload,
|
29
|
+
# propagate null.
|
30
|
+
null false
|
31
|
+
|
32
|
+
def initialize(object:, context:)
|
33
|
+
super
|
34
|
+
# Figure out whether this is an update or an initial subscription
|
35
|
+
@mode = context.query.subscription_update? ? :update : :subscribe
|
36
|
+
end
|
37
|
+
|
38
|
+
# Implement the {Resolve} API
|
39
|
+
def resolve(**args)
|
40
|
+
# Dispatch based on `@mode`, which will raise a `NoMethodError` if we ever
|
41
|
+
# have an unexpected `@mode`
|
42
|
+
public_send("resolve_#{@mode}", args)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Wrap the user-defined `#subscribe` hook
|
46
|
+
def resolve_subscribe(args)
|
47
|
+
ret_val = args.any? ? subscribe(args) : subscribe
|
48
|
+
if ret_val == :no_response
|
49
|
+
context.skip
|
50
|
+
else
|
51
|
+
ret_val
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Default implementation returns the root object.
|
56
|
+
# Override it to return an object or
|
57
|
+
# `:no_response` to return nothing.
|
58
|
+
#
|
59
|
+
# The default is `:no_response`.
|
60
|
+
def subscribe(args)
|
61
|
+
:no_response
|
62
|
+
end
|
63
|
+
|
64
|
+
# Wrap the user-provided `#update` hook
|
65
|
+
def resolve_update(args)
|
66
|
+
ret_val = args.any? ? update(args) : update
|
67
|
+
if ret_val == :no_update
|
68
|
+
raise NoUpdateError
|
69
|
+
else
|
70
|
+
ret_val
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# The default implementation returns the root object.
|
75
|
+
# Override it to return `:no_update` if you want to
|
76
|
+
# skip updates sometimes. Or override it to return a different object.
|
77
|
+
def update(args = {})
|
78
|
+
object
|
79
|
+
end
|
80
|
+
|
81
|
+
# If an argument is flagged with `loads:` and no object is found for it,
|
82
|
+
# remove this subscription (assuming that the object was deleted in the meantime,
|
83
|
+
# or that it became inaccessible).
|
84
|
+
def load_application_object_failed(err)
|
85
|
+
if @mode == :update
|
86
|
+
unsubscribe
|
87
|
+
end
|
88
|
+
super
|
89
|
+
end
|
90
|
+
|
91
|
+
# Call this to halt execution and remove this subscription from the system
|
92
|
+
def unsubscribe
|
93
|
+
raise UnsubscribedError
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -100,6 +100,12 @@ module GraphQL
|
|
100
100
|
}
|
101
101
|
)
|
102
102
|
deliver(subscription_id, result)
|
103
|
+
rescue GraphQL::Schema::Subscription::NoUpdateError
|
104
|
+
# This update was skipped in user code; do nothing.
|
105
|
+
rescue GraphQL::Schema::Subscription::UnsubscribedError
|
106
|
+
# `unsubscribe` was called, clean up on our side
|
107
|
+
# TODO also send `{more: false}` to client?
|
108
|
+
delete_subscription(subscription_id)
|
103
109
|
end
|
104
110
|
|
105
111
|
# Event `event` occurred on `object`,
|
@@ -133,9 +139,8 @@ module GraphQL
|
|
133
139
|
# The result should be send to `subscription_id`.
|
134
140
|
# @param subscription_id [String]
|
135
141
|
# @param result [Hash]
|
136
|
-
# @param context [GraphQL::Query::Context]
|
137
142
|
# @return [void]
|
138
|
-
def deliver(subscription_id, result
|
143
|
+
def deliver(subscription_id, result)
|
139
144
|
raise NotImplementedError
|
140
145
|
end
|
141
146
|
|
@@ -148,6 +153,14 @@ module GraphQL
|
|
148
153
|
raise NotImplementedError
|
149
154
|
end
|
150
155
|
|
156
|
+
# A subscription was terminated server-side.
|
157
|
+
# Clean up the database.
|
158
|
+
# @param subscription_id [String]
|
159
|
+
# @return void.
|
160
|
+
def delete_subscription(subscription_id)
|
161
|
+
raise NotImplementedError
|
162
|
+
end
|
163
|
+
|
151
164
|
# @return [String] A new unique identifier for a subscription
|
152
165
|
def build_id
|
153
166
|
SecureRandom.uuid
|
@@ -4,8 +4,30 @@ module GraphQL
|
|
4
4
|
class Subscriptions
|
5
5
|
# Extend this module in your subscription root when using {GraphQL::Execution::Interpreter}.
|
6
6
|
module SubscriptionRoot
|
7
|
+
def self.extended(child_cls)
|
8
|
+
child_cls.include(InstanceMethods)
|
9
|
+
end
|
10
|
+
|
11
|
+
# This is for maintaining backwards compatibility:
|
12
|
+
# if a subscription field is created without a `subscription:` resolver class,
|
13
|
+
# then implement the method with the previous default behavior.
|
14
|
+
module InstanceMethods
|
15
|
+
def skip_subscription_root(*)
|
16
|
+
if context.query.subscription_update?
|
17
|
+
object
|
18
|
+
else
|
19
|
+
context.skip
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
7
24
|
def field(*args, extensions: [], **rest, &block)
|
8
25
|
extensions += [Extension]
|
26
|
+
# Backwards-compat for schemas
|
27
|
+
if !rest[:subscription]
|
28
|
+
name = args.first
|
29
|
+
alias_method(name, :skip_subscription_root)
|
30
|
+
end
|
9
31
|
super(*args, extensions: extensions, **rest, &block)
|
10
32
|
end
|
11
33
|
|
@@ -22,16 +44,17 @@ module GraphQL
|
|
22
44
|
context: context,
|
23
45
|
field: field,
|
24
46
|
)
|
25
|
-
|
47
|
+
# TODO compat with non-class-based subscriptions?
|
48
|
+
value
|
26
49
|
elsif context.query.subscription_topic == Subscriptions::Event.serialize(
|
27
50
|
field.name,
|
28
51
|
arguments,
|
29
52
|
field,
|
30
53
|
scope: (field.subscription_scope ? context[field.subscription_scope] : nil),
|
31
54
|
)
|
32
|
-
# The
|
33
|
-
# it
|
34
|
-
|
55
|
+
# This is a subscription update. The resolver returned `skip` if it should be skipped,
|
56
|
+
# or else it returned an object to resolve the update.
|
57
|
+
value
|
35
58
|
else
|
36
59
|
# This is a subscription update, but this event wasn't triggered.
|
37
60
|
context.skip
|
data/lib/graphql/version.rb
CHANGED
data/spec/dummy/Gemfile.lock
CHANGED
@@ -14,15 +14,15 @@ class GraphqlChannel < ActionCable::Channel::Base
|
|
14
14
|
class SubscriptionType < GraphQL::Schema::Object
|
15
15
|
if TESTING_INTERPRETER
|
16
16
|
extend GraphQL::Subscriptions::SubscriptionRoot
|
17
|
+
else
|
18
|
+
def payload(id:)
|
19
|
+
id
|
20
|
+
end
|
17
21
|
end
|
18
22
|
|
19
23
|
field :payload, PayloadType, null: false do
|
20
24
|
argument :id, ID, required: true
|
21
25
|
end
|
22
|
-
|
23
|
-
def payload(id:)
|
24
|
-
id
|
25
|
-
end
|
26
26
|
end
|
27
27
|
|
28
28
|
# Wacky behavior around the number 4
|
data/spec/dummy/log/test.log
CHANGED
@@ -194,3 +194,6 @@ GraphqlChannel transmitting {"result"=>{"data"=>{"payload"=>{"value"=>400}}}, "m
|
|
194
194
|
Finished "/cable/" [WebSocket] for 127.0.0.1 at 2018-10-05 11:46:25 -0400
|
195
195
|
GraphqlChannel stopped streaming from graphql-subscription:c781954b-7b9a-4742-9f9c-69b869d85fad
|
196
196
|
GraphqlChannel stopped streaming from graphql-event::payload:id:updates-2
|
197
|
+
-----------------------------------------------------------
|
198
|
+
ActionCableSubscriptionsTest: test_it_handles_subscriptions
|
199
|
+
-----------------------------------------------------------
|
@@ -14,12 +14,19 @@ describe GraphQL::Execution::Lookahead do
|
|
14
14
|
DATA.find { |b| b.name == name }
|
15
15
|
end
|
16
16
|
|
17
|
+
module Node
|
18
|
+
include GraphQL::Schema::Interface
|
19
|
+
field :id, ID, null: false
|
20
|
+
end
|
21
|
+
|
17
22
|
class BirdGenus < GraphQL::Schema::Object
|
18
23
|
field :latin_name, String, null: false
|
24
|
+
field :id, ID, null: false, method: :latin_name
|
19
25
|
end
|
20
26
|
|
21
27
|
class BirdSpecies < GraphQL::Schema::Object
|
22
28
|
field :name, String, null: false
|
29
|
+
field :id, ID, null: false, method: :name
|
23
30
|
field :is_waterfowl, Boolean, null: false
|
24
31
|
field :similar_species, [BirdSpecies], null: false
|
25
32
|
|
@@ -46,6 +53,18 @@ describe GraphQL::Execution::Lookahead do
|
|
46
53
|
def find_bird_species(by_name:)
|
47
54
|
DATA.find_by_name(by_name)
|
48
55
|
end
|
56
|
+
|
57
|
+
field :node, Node, null: true do
|
58
|
+
argument :id, ID, required: true
|
59
|
+
end
|
60
|
+
|
61
|
+
def node(id:)
|
62
|
+
if (node = DATA.find_by_name(id))
|
63
|
+
node
|
64
|
+
else
|
65
|
+
DATA.map { |d| d.genus }.select { |g| g.name == id }
|
66
|
+
end
|
67
|
+
end
|
49
68
|
end
|
50
69
|
|
51
70
|
class LookaheadInstrumenter
|
@@ -101,6 +120,40 @@ describe GraphQL::Execution::Lookahead do
|
|
101
120
|
assert_equal true, query.lookahead.selects?("__typename")
|
102
121
|
end
|
103
122
|
|
123
|
+
describe "fields on interfaces" do
|
124
|
+
let(:document) {
|
125
|
+
GraphQL.parse <<-GRAPHQL
|
126
|
+
query {
|
127
|
+
node(id: "Cardinal") {
|
128
|
+
id
|
129
|
+
... on BirdSpecies {
|
130
|
+
name
|
131
|
+
}
|
132
|
+
...Other
|
133
|
+
}
|
134
|
+
}
|
135
|
+
fragment Other on BirdGenus {
|
136
|
+
latinName
|
137
|
+
}
|
138
|
+
GRAPHQL
|
139
|
+
}
|
140
|
+
|
141
|
+
it "finds fields on object types and interface types" do
|
142
|
+
node_lookahead = query.lookahead.selection("node")
|
143
|
+
assert_equal [:id, :name, :latin_name], node_lookahead.selections.map(&:name)
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
describe "inspect" do
|
148
|
+
it "works for root lookaheads" do
|
149
|
+
assert_includes query.lookahead.inspect, "#<GraphQL::Execution::Lookahead @root_type="
|
150
|
+
end
|
151
|
+
|
152
|
+
it "works for field lookaheads" do
|
153
|
+
assert_includes query.lookahead.selection(:find_bird_species).inspect, "#<GraphQL::Execution::Lookahead @field="
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
104
157
|
describe "constraints by arguments" do
|
105
158
|
let(:lookahead) do
|
106
159
|
query.lookahead
|
@@ -245,7 +298,7 @@ describe GraphQL::Execution::Lookahead do
|
|
245
298
|
ast_node = document.definitions.first.selections.first
|
246
299
|
field = LookaheadTest::Query.fields["findBirdSpecies"]
|
247
300
|
lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], field: field)
|
248
|
-
assert_equal lookahead.selections.map(&:name)
|
301
|
+
assert_equal [:name, :similar_species], lookahead.selections.map(&:name)
|
249
302
|
end
|
250
303
|
|
251
304
|
it "filters outs selections which do not match arguments" do
|
@@ -257,10 +257,10 @@ describe GraphQL::Query::Executor do
|
|
257
257
|
{
|
258
258
|
"message" => "Variable input of type ReplaceValuesInput! was provided invalid value",
|
259
259
|
"locations" => [{ "line" => 1, "column" => 13 }],
|
260
|
-
"
|
261
|
-
|
262
|
-
{ "path" => [], "explanation" => "Expected value to not be null" }
|
263
|
-
|
260
|
+
"extensions" => {
|
261
|
+
"value" => nil,
|
262
|
+
"problems" => [{ "path" => [], "explanation" => "Expected value to not be null" }]
|
263
|
+
}
|
264
264
|
}
|
265
265
|
]
|
266
266
|
}
|
@@ -277,10 +277,10 @@ describe GraphQL::Query::Executor do
|
|
277
277
|
{
|
278
278
|
"message" => "Variable input of type ReplaceValuesInput! was provided invalid value",
|
279
279
|
"locations" => [{ "line" => 1, "column" => 13 }],
|
280
|
-
"
|
281
|
-
|
282
|
-
{ "path" => ["values"], "explanation" => "Expected value to not be null" }
|
283
|
-
|
280
|
+
"extensions" => {
|
281
|
+
"value" => {},
|
282
|
+
"problems" => [{ "path" => ["values"], "explanation" => "Expected value to not be null" }]
|
283
|
+
}
|
284
284
|
}
|
285
285
|
]
|
286
286
|
}
|
@@ -297,11 +297,13 @@ describe GraphQL::Query::Executor do
|
|
297
297
|
{
|
298
298
|
"message" => "Variable input of type [DairyProductInput] was provided invalid value",
|
299
299
|
"locations" => [{ "line" => 1, "column" => 10 }],
|
300
|
-
"
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
300
|
+
"extensions" => {
|
301
|
+
"value" => [{ "foo" => "bar" }],
|
302
|
+
"problems" => [
|
303
|
+
{ "path" => [0, "foo"], "explanation" => "Field is not defined on DairyProductInput" },
|
304
|
+
{ "path" => [0, "source"], "explanation" => "Expected value to not be null" }
|
305
|
+
]
|
306
|
+
}
|
305
307
|
}
|
306
308
|
]
|
307
309
|
}
|
data/spec/graphql/query_spec.rb
CHANGED
@@ -435,8 +435,10 @@ describe GraphQL::Query do
|
|
435
435
|
{
|
436
436
|
"message" => "Variable cheeseId of type Int! was provided invalid value",
|
437
437
|
"locations"=>[{ "line" => 2, "column" => 23 }],
|
438
|
-
"
|
439
|
-
|
438
|
+
"extensions" => {
|
439
|
+
"value" => "2",
|
440
|
+
"problems" => [{ "path" => [], "explanation" => 'Could not coerce value "2" to Int' }]
|
441
|
+
}
|
440
442
|
}
|
441
443
|
]
|
442
444
|
}
|
@@ -453,8 +455,10 @@ describe GraphQL::Query do
|
|
453
455
|
{
|
454
456
|
"message" => "Variable cheeseId of type Int! was provided invalid value",
|
455
457
|
"locations" => [{"line" => 2, "column" => 23}],
|
456
|
-
"
|
457
|
-
|
458
|
+
"extensions" => {
|
459
|
+
"value" => nil,
|
460
|
+
"problems" => [{ "path" => [], "explanation" => "Expected value to not be null" }]
|
461
|
+
}
|
458
462
|
}
|
459
463
|
]
|
460
464
|
}
|
@@ -471,8 +475,10 @@ describe GraphQL::Query do
|
|
471
475
|
{
|
472
476
|
"message" => "Variable cheeseId of type Int! was provided invalid value",
|
473
477
|
"locations" => [{"line" => 2, "column" => 23}],
|
474
|
-
"
|
475
|
-
|
478
|
+
"extensions" => {
|
479
|
+
"value" => nil,
|
480
|
+
"problems" => [{ "path" => [], "explanation" => "Expected value to not be null" }]
|
481
|
+
}
|
476
482
|
}
|
477
483
|
]
|
478
484
|
}
|
@@ -0,0 +1,416 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "spec_helper"
|
3
|
+
|
4
|
+
describe GraphQL::Schema::Subscription do
|
5
|
+
class SubscriptionFieldSchema < GraphQL::Schema
|
6
|
+
TOOTS = []
|
7
|
+
ALL_USERS = {
|
8
|
+
"dhh" => {handle: "dhh", private: false},
|
9
|
+
"matz" => {handle: "matz", private: false},
|
10
|
+
"_why" => {handle: "_why", private: true},
|
11
|
+
}
|
12
|
+
|
13
|
+
USERS = {}
|
14
|
+
|
15
|
+
class User < GraphQL::Schema::Object
|
16
|
+
field :handle, String, null: false
|
17
|
+
field :private, Boolean, null: false
|
18
|
+
end
|
19
|
+
|
20
|
+
class Toot < GraphQL::Schema::Object
|
21
|
+
field :handle, String, null: false
|
22
|
+
field :body, String, null: false
|
23
|
+
end
|
24
|
+
|
25
|
+
class Query < GraphQL::Schema::Object
|
26
|
+
field :toots, [Toot], null: false
|
27
|
+
|
28
|
+
def toots
|
29
|
+
TOOTS
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
class BaseSubscription < GraphQL::Schema::Subscription
|
34
|
+
end
|
35
|
+
|
36
|
+
class TootWasTooted < BaseSubscription
|
37
|
+
argument :handle, String, required: true, loads: User, as: :user
|
38
|
+
field :toot, Toot, null: false
|
39
|
+
field :user, User, null: false
|
40
|
+
# Can't subscribe to private users
|
41
|
+
def authorized?(user:)
|
42
|
+
if user[:private]
|
43
|
+
raise GraphQL::ExecutionError, "Can't subscribe to private user"
|
44
|
+
else
|
45
|
+
true
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def subscribe(user:)
|
50
|
+
if context[:prohibit_subscriptions]
|
51
|
+
raise GraphQL::ExecutionError, "You don't have permission to subscribe"
|
52
|
+
else
|
53
|
+
# Default is to return :no_response
|
54
|
+
super
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def update(user:)
|
59
|
+
if context[:viewer] == user
|
60
|
+
# don't update for one's own toots.
|
61
|
+
# (IRL it would make more sense to implement this in `#subscribe`)
|
62
|
+
:no_update
|
63
|
+
else
|
64
|
+
# This assumes that trigger object can fulfill `{toot:, user:}`,
|
65
|
+
# for testing that the default implementation is `return object`
|
66
|
+
super
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# Test initial response, which returns all users
|
72
|
+
class UsersJoined < BaseSubscription
|
73
|
+
class UsersJoinedManualPayload < GraphQL::Schema::Object
|
74
|
+
field :users, [User], null: true,
|
75
|
+
description: "Includes newly-created users, or all users on the initial load"
|
76
|
+
end
|
77
|
+
|
78
|
+
payload_type UsersJoinedManualPayload
|
79
|
+
|
80
|
+
def subscribe
|
81
|
+
{ users: USERS.values }
|
82
|
+
end
|
83
|
+
|
84
|
+
# Test returning a custom object from #update
|
85
|
+
def update
|
86
|
+
{ users: object[:new_users] }
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
class Subscription < GraphQL::Schema::Object
|
91
|
+
extend GraphQL::Subscriptions::SubscriptionRoot
|
92
|
+
field :toot_was_tooted, subscription: TootWasTooted
|
93
|
+
field :users_joined, subscription: UsersJoined
|
94
|
+
end
|
95
|
+
|
96
|
+
class Mutation < GraphQL::Schema::Object
|
97
|
+
field :toot, Toot, null: false do
|
98
|
+
argument :body, String, required: true
|
99
|
+
end
|
100
|
+
|
101
|
+
def toot(body:)
|
102
|
+
handle = context[:viewer][:handle]
|
103
|
+
toot = { handle: handle, body: body }
|
104
|
+
TOOTS << toot
|
105
|
+
SubscriptionFieldSchema.trigger(:toot_was_tooted, {handle: handle}, toot)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
query(Query)
|
110
|
+
mutation(Mutation)
|
111
|
+
subscription(Subscription)
|
112
|
+
use GraphQL::Execution::Interpreter
|
113
|
+
use GraphQL::Analysis::AST
|
114
|
+
|
115
|
+
def self.object_from_id(id, ctx)
|
116
|
+
USERS[id]
|
117
|
+
end
|
118
|
+
|
119
|
+
|
120
|
+
class InMemorySubscriptions < GraphQL::Subscriptions
|
121
|
+
SUBSCRIPTION_REGISTRY = {}
|
122
|
+
|
123
|
+
EVENT_REGISTRY = Hash.new { |h, k| h[k] = [] }
|
124
|
+
|
125
|
+
def write_subscription(query, events)
|
126
|
+
query.context[:subscription_mailbox] = []
|
127
|
+
subscription_id = build_id
|
128
|
+
events.each do |ev|
|
129
|
+
EVENT_REGISTRY[ev.topic] << subscription_id
|
130
|
+
end
|
131
|
+
SUBSCRIPTION_REGISTRY[subscription_id] = [query, events]
|
132
|
+
end
|
133
|
+
|
134
|
+
def each_subscription_id(event)
|
135
|
+
EVENT_REGISTRY[event.topic].each do |sub_id|
|
136
|
+
yield(sub_id)
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def read_subscription(subscription_id)
|
141
|
+
query, _events = SUBSCRIPTION_REGISTRY[subscription_id]
|
142
|
+
{
|
143
|
+
query_string: query.query_string,
|
144
|
+
context: query.context.to_h,
|
145
|
+
variables: query.provided_variables,
|
146
|
+
operation_name: query.selected_operation_name,
|
147
|
+
}
|
148
|
+
end
|
149
|
+
|
150
|
+
def deliver(subscription_id, result)
|
151
|
+
query, _events = SUBSCRIPTION_REGISTRY[subscription_id]
|
152
|
+
query.context[:subscription_mailbox] << result
|
153
|
+
end
|
154
|
+
|
155
|
+
def delete_subscription(subscription_id)
|
156
|
+
_query, events = SUBSCRIPTION_REGISTRY.delete(subscription_id)
|
157
|
+
events.each do |ev|
|
158
|
+
EVENT_REGISTRY[ev.topic].delete(subscription_id)
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
use InMemorySubscriptions
|
164
|
+
end
|
165
|
+
|
166
|
+
def exec_query(*args)
|
167
|
+
SubscriptionFieldSchema.execute(*args)
|
168
|
+
end
|
169
|
+
|
170
|
+
def in_memory_subscription_count
|
171
|
+
SubscriptionFieldSchema::InMemorySubscriptions::SUBSCRIPTION_REGISTRY.size
|
172
|
+
end
|
173
|
+
|
174
|
+
before do
|
175
|
+
# Reset databases
|
176
|
+
SubscriptionFieldSchema::TOOTS.clear
|
177
|
+
# Reset in order:
|
178
|
+
SubscriptionFieldSchema::USERS.clear
|
179
|
+
SubscriptionFieldSchema::ALL_USERS.map do |k, v|
|
180
|
+
SubscriptionFieldSchema::USERS[k] = v.dup
|
181
|
+
end
|
182
|
+
|
183
|
+
SubscriptionFieldSchema::InMemorySubscriptions::SUBSCRIPTION_REGISTRY.clear
|
184
|
+
SubscriptionFieldSchema::InMemorySubscriptions::EVENT_REGISTRY.clear
|
185
|
+
end
|
186
|
+
|
187
|
+
it "generates a return type" do
|
188
|
+
return_type = SubscriptionFieldSchema::TootWasTooted.payload_type
|
189
|
+
assert_equal "TootWasTootedPayload", return_type.graphql_name
|
190
|
+
assert_equal ["toot", "user"], return_type.fields.keys
|
191
|
+
end
|
192
|
+
|
193
|
+
it "can use a premade `payload_type`" do
|
194
|
+
return_type = SubscriptionFieldSchema::UsersJoined.payload_type
|
195
|
+
assert_equal "UsersJoinedManualPayload", return_type.graphql_name
|
196
|
+
assert_equal ["users"], return_type.fields.keys
|
197
|
+
assert_equal SubscriptionFieldSchema::UsersJoined::UsersJoinedManualPayload, return_type
|
198
|
+
end
|
199
|
+
|
200
|
+
describe "initial subscription" do
|
201
|
+
it "calls #subscribe for the initial subscription and returns the result" do
|
202
|
+
res = exec_query <<-GRAPHQL
|
203
|
+
subscription {
|
204
|
+
usersJoined {
|
205
|
+
users {
|
206
|
+
handle
|
207
|
+
}
|
208
|
+
}
|
209
|
+
}
|
210
|
+
GRAPHQL
|
211
|
+
|
212
|
+
assert_equal ["dhh", "matz", "_why"], res["data"]["usersJoined"]["users"].map { |u| u["handle"] }
|
213
|
+
assert_equal 1, in_memory_subscription_count
|
214
|
+
end
|
215
|
+
|
216
|
+
it "rejects the subscription if #subscribe raises an error" do
|
217
|
+
res = exec_query <<-GRAPHQL, context: { prohibit_subscriptions: true }
|
218
|
+
subscription {
|
219
|
+
tootWasTooted(handle: "matz") {
|
220
|
+
toot { body }
|
221
|
+
}
|
222
|
+
}
|
223
|
+
GRAPHQL
|
224
|
+
|
225
|
+
expected_response = {
|
226
|
+
"data"=>nil,
|
227
|
+
"errors"=>[
|
228
|
+
{
|
229
|
+
"message"=>"You don't have permission to subscribe",
|
230
|
+
"locations"=>[{"line"=>2, "column"=>9}],
|
231
|
+
"path"=>["tootWasTooted"]
|
232
|
+
}
|
233
|
+
]
|
234
|
+
}
|
235
|
+
|
236
|
+
assert_equal(expected_response, res)
|
237
|
+
assert_equal 0, in_memory_subscription_count
|
238
|
+
end
|
239
|
+
|
240
|
+
it "doesn't subscribe if `loads:` fails" do
|
241
|
+
res = exec_query <<-GRAPHQL
|
242
|
+
subscription {
|
243
|
+
tootWasTooted(handle: "jack") {
|
244
|
+
toot { body }
|
245
|
+
}
|
246
|
+
}
|
247
|
+
GRAPHQL
|
248
|
+
|
249
|
+
expected_response = {
|
250
|
+
"data" => nil,
|
251
|
+
"errors" => [
|
252
|
+
{
|
253
|
+
"message"=>"No object found for `handle: \"jack\"`",
|
254
|
+
"locations"=>[{"line"=>2, "column"=>9}],
|
255
|
+
"path"=>["tootWasTooted"]
|
256
|
+
}
|
257
|
+
]
|
258
|
+
}
|
259
|
+
assert_equal(expected_response, res)
|
260
|
+
assert_equal 0, in_memory_subscription_count
|
261
|
+
end
|
262
|
+
|
263
|
+
it "rejects if #authorized? fails" do
|
264
|
+
res = exec_query <<-GRAPHQL
|
265
|
+
subscription {
|
266
|
+
tootWasTooted(handle: "_why") {
|
267
|
+
toot { body }
|
268
|
+
}
|
269
|
+
}
|
270
|
+
GRAPHQL
|
271
|
+
expected_response = {
|
272
|
+
"data"=>nil,
|
273
|
+
"errors"=>[
|
274
|
+
{
|
275
|
+
"message"=>"Can't subscribe to private user",
|
276
|
+
"locations"=>[{"line"=>2, "column"=>9}],
|
277
|
+
"path"=>["tootWasTooted"]
|
278
|
+
},
|
279
|
+
],
|
280
|
+
}
|
281
|
+
assert_equal(expected_response, res)
|
282
|
+
end
|
283
|
+
|
284
|
+
it "sends no initial response if :no_response is returned, which is the default" do
|
285
|
+
assert_equal 0, in_memory_subscription_count
|
286
|
+
|
287
|
+
res = exec_query <<-GRAPHQL
|
288
|
+
subscription {
|
289
|
+
tootWasTooted(handle: "matz") {
|
290
|
+
toot { body }
|
291
|
+
}
|
292
|
+
}
|
293
|
+
GRAPHQL
|
294
|
+
assert_equal({"data" => {}}, res)
|
295
|
+
assert_equal 1, in_memory_subscription_count
|
296
|
+
end
|
297
|
+
end
|
298
|
+
|
299
|
+
describe "updates" do
|
300
|
+
it "updates with `object` by default" do
|
301
|
+
res = exec_query <<-GRAPHQL
|
302
|
+
subscription {
|
303
|
+
tootWasTooted(handle: "matz") {
|
304
|
+
toot { body }
|
305
|
+
}
|
306
|
+
}
|
307
|
+
GRAPHQL
|
308
|
+
assert_equal 1, in_memory_subscription_count
|
309
|
+
obj = OpenStruct.new(toot: { body: "I am a C programmer" }, user: SubscriptionFieldSchema::USERS["matz"])
|
310
|
+
SubscriptionFieldSchema.subscriptions.trigger(:toot_was_tooted, {handle: "matz"}, obj)
|
311
|
+
|
312
|
+
mailbox = res.context[:subscription_mailbox]
|
313
|
+
update_payload = mailbox.first
|
314
|
+
assert_equal "I am a C programmer", update_payload["data"]["tootWasTooted"]["toot"]["body"]
|
315
|
+
end
|
316
|
+
|
317
|
+
it "updates with the returned value" do
|
318
|
+
res = exec_query <<-GRAPHQL
|
319
|
+
subscription {
|
320
|
+
usersJoined {
|
321
|
+
users {
|
322
|
+
handle
|
323
|
+
}
|
324
|
+
}
|
325
|
+
}
|
326
|
+
GRAPHQL
|
327
|
+
|
328
|
+
assert_equal 1, in_memory_subscription_count
|
329
|
+
SubscriptionFieldSchema.subscriptions.trigger(:users_joined, {}, {new_users: [{handle: "eileencodes"}, {handle: "tenderlove"}]})
|
330
|
+
|
331
|
+
update = res.context[:subscription_mailbox].first
|
332
|
+
assert_equal [{"handle" => "eileencodes"}, {"handle" => "tenderlove"}], update["data"]["usersJoined"]["users"]
|
333
|
+
end
|
334
|
+
|
335
|
+
it "skips the update if `:no_update` is returned, but updates other subscribers" do
|
336
|
+
query_str = <<-GRAPHQL
|
337
|
+
subscription {
|
338
|
+
tootWasTooted(handle: "matz") {
|
339
|
+
toot { body }
|
340
|
+
}
|
341
|
+
}
|
342
|
+
GRAPHQL
|
343
|
+
|
344
|
+
res1 = exec_query(query_str)
|
345
|
+
res2 = exec_query(query_str, context: { viewer: SubscriptionFieldSchema::USERS["matz"] })
|
346
|
+
assert_equal 2, in_memory_subscription_count
|
347
|
+
|
348
|
+
obj = OpenStruct.new(toot: { body: "Merry Christmas, here's a new Ruby version" }, user: SubscriptionFieldSchema::USERS["matz"])
|
349
|
+
SubscriptionFieldSchema.subscriptions.trigger(:toot_was_tooted, {handle: "matz"}, obj)
|
350
|
+
|
351
|
+
mailbox1 = res1.context[:subscription_mailbox]
|
352
|
+
mailbox2 = res2.context[:subscription_mailbox]
|
353
|
+
# The anonymous viewer got an update:
|
354
|
+
assert_equal "Merry Christmas, here's a new Ruby version", mailbox1.first["data"]["tootWasTooted"]["toot"]["body"]
|
355
|
+
# But not matz:
|
356
|
+
assert_equal [], mailbox2
|
357
|
+
end
|
358
|
+
|
359
|
+
it "unsubscribes if a `loads:` argument is not found" do
|
360
|
+
res = exec_query <<-GRAPHQL
|
361
|
+
subscription {
|
362
|
+
tootWasTooted(handle: "matz") {
|
363
|
+
toot { body }
|
364
|
+
}
|
365
|
+
}
|
366
|
+
GRAPHQL
|
367
|
+
assert_equal 1, in_memory_subscription_count
|
368
|
+
obj = OpenStruct.new(toot: { body: "I am a C programmer" }, user: SubscriptionFieldSchema::USERS["matz"])
|
369
|
+
SubscriptionFieldSchema.subscriptions.trigger(:toot_was_tooted, {handle: "matz"}, obj)
|
370
|
+
|
371
|
+
# Get 1 successful update
|
372
|
+
mailbox = res.context[:subscription_mailbox]
|
373
|
+
assert_equal 1, mailbox.size
|
374
|
+
update_payload = mailbox.first
|
375
|
+
assert_equal "I am a C programmer", update_payload["data"]["tootWasTooted"]["toot"]["body"]
|
376
|
+
|
377
|
+
# Then cause a not-found and update again
|
378
|
+
matz = SubscriptionFieldSchema::USERS.delete("matz")
|
379
|
+
obj = OpenStruct.new(toot: { body: "Merry Christmas, here's a new Ruby version" }, user: matz)
|
380
|
+
SubscriptionFieldSchema.subscriptions.trigger(:toot_was_tooted, {handle: "matz"}, obj)
|
381
|
+
# there was no subsequent update
|
382
|
+
assert_equal 1, mailbox.size
|
383
|
+
# The database was cleaned up
|
384
|
+
assert_equal 0, in_memory_subscription_count
|
385
|
+
end
|
386
|
+
|
387
|
+
it "sends an error if `#authorized?` fails" do
|
388
|
+
res = exec_query <<-GRAPHQL
|
389
|
+
subscription {
|
390
|
+
tootWasTooted(handle: "matz") {
|
391
|
+
toot { body }
|
392
|
+
}
|
393
|
+
}
|
394
|
+
GRAPHQL
|
395
|
+
assert_equal 1, in_memory_subscription_count
|
396
|
+
matz = SubscriptionFieldSchema::USERS["matz"]
|
397
|
+
obj = OpenStruct.new(toot: { body: "I am a C programmer" }, user: matz)
|
398
|
+
SubscriptionFieldSchema.subscriptions.trigger(:toot_was_tooted, {handle: "matz"}, obj)
|
399
|
+
|
400
|
+
# Get 1 successful update
|
401
|
+
mailbox = res.context[:subscription_mailbox]
|
402
|
+
assert_equal 1, mailbox.size
|
403
|
+
update_payload = mailbox.first
|
404
|
+
assert_equal "I am a C programmer", update_payload["data"]["tootWasTooted"]["toot"]["body"]
|
405
|
+
|
406
|
+
# Cause an authorized failure
|
407
|
+
matz[:private] = true
|
408
|
+
obj = OpenStruct.new(toot: { body: "Merry Christmas, here's a new Ruby version" }, user: matz)
|
409
|
+
SubscriptionFieldSchema.subscriptions.trigger(:toot_was_tooted, {handle: "matz"}, obj)
|
410
|
+
assert_equal 2, mailbox.size
|
411
|
+
assert_equal ["Can't subscribe to private user"], mailbox.last["errors"].map { |e| e["message"] }
|
412
|
+
# The subscription remains in place
|
413
|
+
assert_equal 1, in_memory_subscription_count
|
414
|
+
end
|
415
|
+
end
|
416
|
+
end
|