graphql 1.9.0.pre2 → 1.9.0.pre3
Sign up to get free protection for your applications and to get access to all the features.
- 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
|