graphql-streaming 0.1.0
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 +7 -0
- data/.gitignore +9 -0
- data/.travis.yml +4 -0
- data/Gemfile +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +246 -0
- data/Rakefile +10 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/graphql-streaming.gemspec +27 -0
- data/lib/graphql/streaming.rb +10 -0
- data/lib/graphql/streaming/action_cable_collector.rb +69 -0
- data/lib/graphql/streaming/action_cable_subscriber.rb +87 -0
- data/lib/graphql/streaming/assign_subscription_field.rb +39 -0
- data/lib/graphql/streaming/clients/graphql-streaming/graphql_channel.js +90 -0
- data/lib/graphql/streaming/clients/graphql-streaming/streaming_graphql_client.js +77 -0
- data/lib/graphql/streaming/railtie.rb +9 -0
- data/lib/graphql/streaming/stream_collector.rb +44 -0
- data/lib/graphql/streaming/version.rb +5 -0
- metadata +118 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA1:
|
|
3
|
+
metadata.gz: d5ec7f6cbeb19b73697a7a70311260bd8a741316
|
|
4
|
+
data.tar.gz: 4fa0ca9c16f693ed294860248a55559ef8060471
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1b1ece8cadcbaf967a114bf782f96845cbc5600686278b3a443844a850031b8d935a251ce9cec70e4e16e38b78109a374029b81405aa6798ca69f351d2d68ede
|
|
7
|
+
data.tar.gz: 9a0e5d527a300ae45377bfdf12e6ea3538574f6c2b5b4fd7d2134f08e28b26e20075d8b6e9dc787207d7ce30f2e5981d8e87ba46b0b7b4891650a54f67b1a2b7
|
data/.gitignore
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2016 Robert Mosolgo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# Graphql::Streaming
|
|
2
|
+
|
|
3
|
+
Experimental tools for GraphQL over a long-lived connection, including `subscription`, `@defer` and `@stream`. ([demo](https://github.com/rmosolgo/graphql-ruby-stream-defer-demo))
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Requires an experimental branch of graphql:
|
|
9
|
+
gem "graphql", github: "rmosolgo/graphql-ruby", branch: "defer-directive"
|
|
10
|
+
gem "graphql-streaming"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Defer & Stream
|
|
16
|
+
|
|
17
|
+
This gem supports [queries with `@defer` and `@stream`](https://youtu.be/ViXL0YQnioU?t=12m49s).
|
|
18
|
+
|
|
19
|
+
- Use `GraphQL::Execution::DeferredExecution`:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
# only available in the defer-directive branch:
|
|
23
|
+
MySchema.query_execution_strategy = GraphQL::Execution::DeferredExecution
|
|
24
|
+
MySchema.subscription_execution_strategy = GraphQL::Execution::DeferredExecution
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- Choose a transport ([HTTP Streaming](#http-streaming-transport) or [ActionCable](#actioncable-transport)) and get its client (both built-in clients depend on `Object.assign`)
|
|
28
|
+
|
|
29
|
+
#### HTTP Streaming Transport
|
|
30
|
+
|
|
31
|
+
`StreamCollector` uses `stream.write(patch)` to send chunked responses to the client. For example, you can use it with [`ActionController::Live`](http://api.rubyonrails.org/classes/ActionController/Live.html).
|
|
32
|
+
|
|
33
|
+
Create a `collector` and include it in the query as `context[:collector]`:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
class ChunkedGraphqlsController < ApplicationController
|
|
37
|
+
include ActionController::Live
|
|
38
|
+
|
|
39
|
+
def create
|
|
40
|
+
# ...
|
|
41
|
+
|
|
42
|
+
# initialize the collector with `response.stream`
|
|
43
|
+
context = {
|
|
44
|
+
collector: StreamCollector.new(response.stream)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
Schema.execute(query_string, variables: variables, context: context)
|
|
48
|
+
|
|
49
|
+
# close the stream when the query is done:
|
|
50
|
+
response.stream.close
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
From JavaScript, use `StreamingGraphQLClient` to fetch data:
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
//= require graphql-streaming/streaming_graphql_client
|
|
59
|
+
|
|
60
|
+
onResponse = function(response) {
|
|
61
|
+
// Handle response.errors / response.data
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
StreamingGraphQLClient.fetch(
|
|
65
|
+
"/graphql/",
|
|
66
|
+
`query getPost($postId: Int!){
|
|
67
|
+
post(id: $postId) {
|
|
68
|
+
title
|
|
69
|
+
comments @stream {
|
|
70
|
+
body
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}`,
|
|
74
|
+
{postId: 1},
|
|
75
|
+
onResponse,
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The `onResponse` function will be called with GraphQL response after each patch is added.
|
|
80
|
+
|
|
81
|
+
#### ActionCable Transport
|
|
82
|
+
|
|
83
|
+
You can use [Rails 5's `ActionCable`](http://edgeguides.rubyonrails.org/action_cable_overview.html) to send and receive GraphQL.
|
|
84
|
+
|
|
85
|
+
In your channel, implement an action called `#fetch(data)` for executing GraphQL queries. It should add an `ActionCableCollector` as `context[:collector]`, using `data["query_id"]` from the client. For example:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
class GraphqlChannel < ApplicationCable::Channel
|
|
89
|
+
# ...
|
|
90
|
+
|
|
91
|
+
def fetch(data)
|
|
92
|
+
query_string = data["query"]
|
|
93
|
+
variables = ensure_hash(data["variables"] || {})
|
|
94
|
+
context = {}
|
|
95
|
+
|
|
96
|
+
# Get the query ID, which is added by the GraphQLChannel client
|
|
97
|
+
query_id = data["query_id"]
|
|
98
|
+
# Get a broadcaster, which is the target for patches
|
|
99
|
+
broadcaster = ActionCable.server.broadcaster_for(channel_name)
|
|
100
|
+
# The collector passes patches on to the broadcaster
|
|
101
|
+
context[:collector] = GraphQL::Streaming::ActionCableCollector.new(query_id, broadcaster)
|
|
102
|
+
|
|
103
|
+
# Run the query
|
|
104
|
+
Schema.execute(query_string, variables: variables, context: context)
|
|
105
|
+
|
|
106
|
+
# Tell the client to stop listening for patches
|
|
107
|
+
context[:collector].close
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Then, create a `GraphQLChannel` to make requests. `GraphQLChannel.subscription` contains defaults for `App.cable.subscription.create`:
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
//= require graphql-streaming/graphql_channel
|
|
116
|
+
App.graphqlChannel = App.cable.subscriptions.create("GraphqlChannel", GraphQLChannel.subscription)
|
|
117
|
+
|
|
118
|
+
// OPTIONAL forward log messages to console.log:
|
|
119
|
+
// GraphQLChannel.log = console.log.bind(console)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
And you can provide overrides if you want:
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
// Trigger `graphql-channel:ready` when the channel is connected
|
|
126
|
+
App.graphqlChannel = App.cable.subscriptions.create(
|
|
127
|
+
"GraphqlChannel",
|
|
128
|
+
Object.assign(GraphQLChannel.subscription, {
|
|
129
|
+
connected: function() {
|
|
130
|
+
$(document).trigger("graphql-channel:ready")
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Send queries with `graphqlChannel.fetch`:
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
var queryString = "{ ... }"
|
|
140
|
+
var queryVariables = { /* ... */ }
|
|
141
|
+
var onResponse = function(response) { /* handle response.errors & response.data */}
|
|
142
|
+
App.graphqlChannel.fetch(queryString, queryVariables, onResponse)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The `onResponse` handler will be called with the whole response _each time_ a patch is received.
|
|
146
|
+
|
|
147
|
+
### Subscription
|
|
148
|
+
|
|
149
|
+
`ActionCableSubscriber` uses `ActionCable` as a backend for GraphQL subscriptions. There are three parts:
|
|
150
|
+
|
|
151
|
+
- Send a __subscriber__ along with your query
|
|
152
|
+
- Define a __Subscription type__ which registers subscriptions during resolve
|
|
153
|
+
- Make __triggers__ from application code
|
|
154
|
+
|
|
155
|
+
#### Subscriber
|
|
156
|
+
|
|
157
|
+
The subscriber rides along with your query (as `context[:subscriber]`). It listens for triggers from the application, and when they happen, it re-evaluates the query and pushes an update over its channel.
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
# Get the query ID from the client
|
|
161
|
+
query_id = data["query_id"]
|
|
162
|
+
|
|
163
|
+
context = { current_user: user_from_session(session) }
|
|
164
|
+
|
|
165
|
+
context[:subscriber] = GraphQL::Streaming::ActionCableSubscriber.new(self, query_id) do
|
|
166
|
+
# This block is called when a matching trigger occurs
|
|
167
|
+
context[:current_user].reload
|
|
168
|
+
Schema.execute(query_string, variables: variables, context: context)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
Schema.execute(query_string, variables: variables, context: context)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# If there are no outstanding subscriptions,
|
|
175
|
+
# tell the client to stop listening for patches
|
|
176
|
+
if !context[:subscriber].subscribed?
|
|
177
|
+
context[:collector].close
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
#### Subscription Type
|
|
182
|
+
|
|
183
|
+
`SubscriptionType` is a plain `GraphQL::ObjectType`, but its fields are special. They correspond to application triggers. When a trigger is fired, any subscriber who is listening to the corresponding field will re-evaluate its query.
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
Define subscription fields with `subscription`:
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
SubscriptionType = GraphQL::ObjectType.define do
|
|
190
|
+
name "Subscription"
|
|
191
|
+
subscription :post, PostType do
|
|
192
|
+
argument :id, !types.Int
|
|
193
|
+
resolve -> (obj, args, ctx) {
|
|
194
|
+
Post.find(args[:id])
|
|
195
|
+
}
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
MySchema = GraphQL::Schema.new(subscription: SubscriptionType ...)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
#### Triggers
|
|
203
|
+
|
|
204
|
+
From your application, you can trigger events on subscription fields. For example, to tell clients that a Post with a given ID changed:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
class Post
|
|
208
|
+
def after_commit
|
|
209
|
+
GraphQL::Streaming::ActionCableSubscriber.trigger(:post, {id: id})
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The arguments passed to `.trigger` will be tested against field arguments. Any subscribers who requested a matching query will be updated. For example:
|
|
215
|
+
|
|
216
|
+
```graphql
|
|
217
|
+
subscription {
|
|
218
|
+
post(id: 1) {
|
|
219
|
+
... postFields
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
would be updated by
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
GraphQL::Streaming::ActionCableSubscriber.trigger(:post, {id: 1})
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Development
|
|
231
|
+
|
|
232
|
+
- `bundle exec rake test` to run the tests
|
|
233
|
+
- Sadly, no JS tests! See the [demo repo](https://github.com/rmosolgo/graphql-ruby-stream-defer-demo) for poke-around testing
|
|
234
|
+
|
|
235
|
+
## TODO
|
|
236
|
+
|
|
237
|
+
- What happens to subscriptions when you redeploy or ActionCable loses its connection? Need to handle reconnecting in some way.
|
|
238
|
+
- Handle errors in subscriber block
|
|
239
|
+
- Improve middleware so you don't have to manually close `ActionCableCollector`s
|
|
240
|
+
- Tests for JS?
|
|
241
|
+
- Other platforms (Pusher, HTTP/2)?
|
|
242
|
+
- Public alternative to `@channel.send(:transmit, payload)`?
|
|
243
|
+
|
|
244
|
+
## License
|
|
245
|
+
|
|
246
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bin/console
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "graphql/streaming"
|
|
5
|
+
|
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
|
8
|
+
|
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
|
10
|
+
# require "pry"
|
|
11
|
+
# Pry.start
|
|
12
|
+
|
|
13
|
+
require "irb"
|
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# coding: utf-8
|
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
4
|
+
require 'graphql/streaming/version'
|
|
5
|
+
|
|
6
|
+
Gem::Specification.new do |spec|
|
|
7
|
+
spec.name = "graphql-streaming"
|
|
8
|
+
spec.version = Graphql::Streaming::VERSION
|
|
9
|
+
spec.authors = ["Robert Mosolgo"]
|
|
10
|
+
spec.email = ["rdmosolgo@gmail.com"]
|
|
11
|
+
|
|
12
|
+
spec.summary = %q{Tools for GraphQL over a long-lived connection}
|
|
13
|
+
spec.description = %q{Tools for GraphQL over a long-lived connection}
|
|
14
|
+
spec.homepage = "https://github.com/rmosolgo/graphql-streaming-ruby"
|
|
15
|
+
spec.license = "MIT"
|
|
16
|
+
|
|
17
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
18
|
+
spec.bindir = "exe"
|
|
19
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
20
|
+
spec.require_paths = ["lib"]
|
|
21
|
+
|
|
22
|
+
spec.add_runtime_dependency "graphql"
|
|
23
|
+
|
|
24
|
+
spec.add_development_dependency "bundler", "~> 1.11"
|
|
25
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
|
26
|
+
spec.add_development_dependency "minitest", "~> 5.0"
|
|
27
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
require "graphql"
|
|
2
|
+
require "graphql/streaming/action_cable_collector"
|
|
3
|
+
require "graphql/streaming/action_cable_subscriber"
|
|
4
|
+
require "graphql/streaming/assign_subscription_field"
|
|
5
|
+
require "graphql/streaming/stream_collector"
|
|
6
|
+
require "graphql/streaming/version"
|
|
7
|
+
|
|
8
|
+
if defined?(Rails)
|
|
9
|
+
require "graphql/streaming/railtie"
|
|
10
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module GraphQL
|
|
3
|
+
module Streaming
|
|
4
|
+
# Accept patches from GraphQL and send them to clients via `channel_name`.
|
|
5
|
+
#
|
|
6
|
+
# Patches are also issued with `query_id`. This way, clients on the same channel
|
|
7
|
+
# can tell whether a patch is for their query or someone else's.
|
|
8
|
+
#
|
|
9
|
+
# When a query is finished (no more patches will be sent), the collector can
|
|
10
|
+
# notify clients with {#close}
|
|
11
|
+
#
|
|
12
|
+
# @example Sending patches over ActionCable
|
|
13
|
+
# # Use this middleware to close queries when they're finished:
|
|
14
|
+
# MySchema.middleware << GraphQL::Streaming::ActionCableMiddleware.new
|
|
15
|
+
#
|
|
16
|
+
# class GraphqlChannel < ApplicationCable::Channel
|
|
17
|
+
# # Implement `#fetch(data)`, which corresponds with GraphQLCable client
|
|
18
|
+
# def fetch(data)
|
|
19
|
+
# query_string = data["query"]
|
|
20
|
+
# variables = ensure_hash(data["variables"] || {})
|
|
21
|
+
#
|
|
22
|
+
# # build a collector including `query_id`
|
|
23
|
+
# # which comes from GraphQLCable client
|
|
24
|
+
# broadcaster = ActionCable.server.broadcaster_for(channel_name)
|
|
25
|
+
# query_id = query_id = data["query_id"]
|
|
26
|
+
# collector = GraphQL::Streaming::ActionCableCollector.new(query_id, broadcaster)
|
|
27
|
+
#
|
|
28
|
+
# context = { collector: collector }
|
|
29
|
+
# Schema.execute(query_string, variables: variables, context: context)
|
|
30
|
+
# end
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# @example Tell the client to stop listening for patches
|
|
34
|
+
# collector = GraphQL::Streaming::ActionCableCollector.new(query_id, broadcaster)
|
|
35
|
+
# # ...
|
|
36
|
+
# collector.close
|
|
37
|
+
#
|
|
38
|
+
class ActionCableCollector
|
|
39
|
+
# @param [String] A unique identifier for this query (probably provided by the client)
|
|
40
|
+
# @param [ActionCable::Server::Broadcasting::Broadcaster] The broadcast target for GraphQL's patches
|
|
41
|
+
def initialize(query_id, broadcaster)
|
|
42
|
+
@query_id = query_id
|
|
43
|
+
@broadcaster = broadcaster
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Implements the "collector" API for DeferredExecution.
|
|
47
|
+
# Sends `{patch: {...}, query_id: @query_id}` over `@broadcaster`.
|
|
48
|
+
# @return [void]
|
|
49
|
+
def patch(path:, value:)
|
|
50
|
+
@broadcaster.broadcast({
|
|
51
|
+
query_id: @query_id,
|
|
52
|
+
patch: {
|
|
53
|
+
path: path,
|
|
54
|
+
value: value,
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Broadcast a message to terminate listeners on this query
|
|
60
|
+
# @return [void]
|
|
61
|
+
def close
|
|
62
|
+
@broadcaster.broadcast({
|
|
63
|
+
query_id: @query_id,
|
|
64
|
+
close: true,
|
|
65
|
+
})
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
module GraphQL
|
|
2
|
+
module Streaming
|
|
3
|
+
# A subscriber for a channel-query combo.
|
|
4
|
+
#
|
|
5
|
+
# @example Subscribe to a query in an ActionCable action, re-evaluating it when things change
|
|
6
|
+
# # Initialize `context` ahead of time so it can be closed over in the subscription block
|
|
7
|
+
# context = {}
|
|
8
|
+
#
|
|
9
|
+
# context[:subscriber] = GraphQLSubscriber.new(self, query_id) do
|
|
10
|
+
# # Tell the schema how to re-fetch results:
|
|
11
|
+
# context[:current_user].reload
|
|
12
|
+
# MySchema.execute(query_string, context: context, variables: variables)
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# # Run the query
|
|
16
|
+
# MySchema.execute(query_string, context: context, variables: variables)
|
|
17
|
+
#
|
|
18
|
+
# # detect whether any subscriptions were added
|
|
19
|
+
# context[:subscriber].subscribed?
|
|
20
|
+
#
|
|
21
|
+
class ActionCableSubscriber
|
|
22
|
+
CHANNEL_PREFIX = "graphql_subscription_"
|
|
23
|
+
|
|
24
|
+
# @param [ActionCable::Channel::Base] The channel to push updates to
|
|
25
|
+
# @param [Object] The query who the updates belong to (probably provided by the client)
|
|
26
|
+
# @yield Reruns the query
|
|
27
|
+
def initialize(channel, query_id, &query_exec)
|
|
28
|
+
@channel = channel
|
|
29
|
+
@query_id = query_id
|
|
30
|
+
@query_exec = query_exec
|
|
31
|
+
@subscribed = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Trigger an event with arguments
|
|
36
|
+
#
|
|
37
|
+
# @example Trigger post_changed
|
|
38
|
+
# # First, subscribe with "{ post_changed(id: 1) { title } }"
|
|
39
|
+
# # Then, trigger the event:
|
|
40
|
+
# GraphQL::Streaming::ActionCableSubscriber.trigger(:post_changed, {id: 1})
|
|
41
|
+
#
|
|
42
|
+
# @param [Symbol] The subscription name to trigger
|
|
43
|
+
# @param [Hash] Arguments to send with the subscription
|
|
44
|
+
def self.trigger(subscription_handle, trigger_options = {})
|
|
45
|
+
ActionCable.server.broadcast("#{CHANNEL_PREFIX}#{subscription_handle}", trigger_options)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def register(subscription_handle, arguments)
|
|
49
|
+
@subscribed = true
|
|
50
|
+
original_args = stringify_hash(arguments)
|
|
51
|
+
|
|
52
|
+
@channel.stream_from("#{CHANNEL_PREFIX}#{subscription_handle}") do |trigger_json|
|
|
53
|
+
trigger_args = JSON.parse(trigger_json)
|
|
54
|
+
if original_args == trigger_args
|
|
55
|
+
payload = {
|
|
56
|
+
patch: {
|
|
57
|
+
path: [],
|
|
58
|
+
value: @query_exec.call,
|
|
59
|
+
},
|
|
60
|
+
query_id: @query_id,
|
|
61
|
+
}
|
|
62
|
+
# TODO: request public method for this?
|
|
63
|
+
@channel.send(:transmit, payload)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Boolean] True if this subscriber registered any subscriptions
|
|
69
|
+
def subscribed?
|
|
70
|
+
@subscribed
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def stringify_hash(value)
|
|
76
|
+
case value
|
|
77
|
+
when Hash
|
|
78
|
+
value.inject({}) { |memo, (k, v)| memo[k.to_s] = stringify_hash(v); memo }
|
|
79
|
+
when Array
|
|
80
|
+
value.map { |v| stringify_hash(v) }
|
|
81
|
+
else
|
|
82
|
+
value
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module GraphQL
|
|
2
|
+
module Streaming
|
|
3
|
+
module AssignSubscriptionField
|
|
4
|
+
# Wrap resolve_proc with subscription registration logic.
|
|
5
|
+
#
|
|
6
|
+
# @example Lookup a post and subscribe to it
|
|
7
|
+
# subscription :post, PostType do
|
|
8
|
+
# argument :id, !types.Int
|
|
9
|
+
# resolve -> (obj, args, ctx) {
|
|
10
|
+
# Post.find(args[:id])
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
def self.call(*args, &block)
|
|
14
|
+
underlying_field = GraphQL::Define::AssignObjectField.call(*args, &block)
|
|
15
|
+
# ensure defined
|
|
16
|
+
# TODO: resolve_proc should be a lazy attr reader
|
|
17
|
+
field_name = underlying_field.name
|
|
18
|
+
original_resolve = underlying_field.resolve_proc
|
|
19
|
+
underlying_field.resolve = SubscriptionResolve.new(field_name, original_resolve)
|
|
20
|
+
# Field was assigned to type_defn in AssignObjectField
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class SubscriptionResolve
|
|
24
|
+
def initialize(subscription_handle, resolve_proc)
|
|
25
|
+
@subscription_handle = subscription_handle
|
|
26
|
+
@resolve_proc = resolve_proc
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(obj, args, ctx)
|
|
30
|
+
subscriber = ctx[:subscriber]
|
|
31
|
+
subscriber && subscriber.register(@subscription_handle, args.to_h)
|
|
32
|
+
@resolve_proc.call(obj, args, ctx)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
GraphQL::ObjectType.accepts_definitions(subscription: AssignSubscriptionField)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
if (!Object.assign) { throw "GraphQLChannel requires Object.assign" }
|
|
2
|
+
|
|
3
|
+
var GraphQLChannel = {
|
|
4
|
+
_getQueryId: function() {
|
|
5
|
+
return Date.now()
|
|
6
|
+
},
|
|
7
|
+
|
|
8
|
+
// Override to log sends and receives
|
|
9
|
+
log: function() {
|
|
10
|
+
// pass
|
|
11
|
+
},
|
|
12
|
+
|
|
13
|
+
subscription: {
|
|
14
|
+
// Called by server-sent events
|
|
15
|
+
received: function(data) {
|
|
16
|
+
GraphQLChannel.log("[GraphQLChannel received]", data)
|
|
17
|
+
var queryId = data.query_id
|
|
18
|
+
var entry = GraphQLChannel.registry.get(queryId)
|
|
19
|
+
if (!entry) {
|
|
20
|
+
// The queryId doesn't exist on this client,
|
|
21
|
+
// it must be for another one of this user's tabs
|
|
22
|
+
} else if (data.close) {
|
|
23
|
+
// An existing request is now completed
|
|
24
|
+
GraphQLChannel.log("[GraphQLChannel closing]", queryId)
|
|
25
|
+
GraphQLChannel.registry.unset(queryId)
|
|
26
|
+
} else {
|
|
27
|
+
// Patch for an existing request
|
|
28
|
+
GraphQLChannel.registry.mergePatch(entry, data.patch)
|
|
29
|
+
entry.onResponse(entry.responseData)
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
fetch: function(queryString, variables, onResponse) {
|
|
34
|
+
var queryId = GraphQLChannel._getQueryId()
|
|
35
|
+
GraphQLChannel.registry.set(queryId, onResponse)
|
|
36
|
+
|
|
37
|
+
GraphQLChannel.log("[GraphQLChannel sending]", queryString, variables, queryId)
|
|
38
|
+
this.perform("fetch", {
|
|
39
|
+
query: queryString,
|
|
40
|
+
variables: JSON.stringify(variables),
|
|
41
|
+
query_id: queryId,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// This registry keeps track of outstanding requests
|
|
47
|
+
registry: {
|
|
48
|
+
// {queryId => entry} pairs of outstanding queries.
|
|
49
|
+
// They should be removed when we receive a payload with `close: true`
|
|
50
|
+
_requests: {},
|
|
51
|
+
|
|
52
|
+
// Store handlers & results for `queryId`.
|
|
53
|
+
// These handlers & results can be fetched by `.get(queryId)`
|
|
54
|
+
set: function(queryId, onResponse) {
|
|
55
|
+
var entry = {
|
|
56
|
+
queryId: queryId,
|
|
57
|
+
onResponse: onResponse,
|
|
58
|
+
responseData: {},
|
|
59
|
+
}
|
|
60
|
+
this._requests[queryId] = entry
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
get: function(queryId) {
|
|
64
|
+
return this._requests[queryId]
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
unset: function(queryId) {
|
|
68
|
+
delete this._requests[queryId]
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
// Mutate `entry` by merging `patch` into its `responseData`
|
|
72
|
+
mergePatch: function(entry, patch) {
|
|
73
|
+
if (patch.path.length === 0) {
|
|
74
|
+
entry.responseData = patch.value
|
|
75
|
+
} else {
|
|
76
|
+
var targetHash = entry.responseData
|
|
77
|
+
var steps = patch.path.slice(0, patch.path.length - 1)
|
|
78
|
+
var lastKey = patch.path[patch.path.length - 1]
|
|
79
|
+
steps.forEach(function(step) {
|
|
80
|
+
var nextStep = targetHash[step]
|
|
81
|
+
if (nextStep == null) {
|
|
82
|
+
nextStep = targetHash[step] = {}
|
|
83
|
+
}
|
|
84
|
+
targetHash = nextStep
|
|
85
|
+
})
|
|
86
|
+
targetHash[lastKey] = patch.value
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
if (!Object.assign) { throw "StreamingGraphQLClient requires Object.assign" }
|
|
2
|
+
|
|
3
|
+
var StreamingGraphQLClient = {
|
|
4
|
+
// Send a GraphQL query to the server,
|
|
5
|
+
// then receive patches from `\n\n`-delimited chunks.
|
|
6
|
+
// For each patch, call `onResponse` with the updated response (data and errors)
|
|
7
|
+
// @example Send a GraphQL query and handle the response
|
|
8
|
+
// var queryString = "{ items @stream { name } }"
|
|
9
|
+
// var queryVariables = {}
|
|
10
|
+
//
|
|
11
|
+
// // Response handler checks for errors
|
|
12
|
+
// var handleResponse = function(response) {
|
|
13
|
+
// if (response.errors) {
|
|
14
|
+
// alert("Uh oh, something went wrong!\n\n" + JSON.stringify(response.errors))
|
|
15
|
+
// } else {
|
|
16
|
+
// _this.setState({items: response.items})
|
|
17
|
+
// }
|
|
18
|
+
// }
|
|
19
|
+
//
|
|
20
|
+
// StreamingGraphQL.fetch("/graphql", queryString, queryVariables, handleResponse)
|
|
21
|
+
//
|
|
22
|
+
fetch: function(endpointPath, queryString, variables, onResponse) {
|
|
23
|
+
var xhr = new XMLHttpRequest()
|
|
24
|
+
xhr.open("POST", endpointPath)
|
|
25
|
+
xhr.setRequestHeader('Content-Type', 'application/json')
|
|
26
|
+
var csrfToken = $('meta[name="csrf-token"]').attr('content')
|
|
27
|
+
xhr.setRequestHeader('X-CSRF-Token', csrfToken)
|
|
28
|
+
|
|
29
|
+
// This will collect patches to publish to `onResponse`
|
|
30
|
+
var responseData = {}
|
|
31
|
+
|
|
32
|
+
// It seems like `onprogress` was called once with the first _two_ patches.
|
|
33
|
+
// Track the index to make sure we don't miss any double-patches.
|
|
34
|
+
var nextPatchIdx = 0
|
|
35
|
+
|
|
36
|
+
var _this = this
|
|
37
|
+
xhr.onprogress = function () {
|
|
38
|
+
// responseText grows; we only care about the most recent patch
|
|
39
|
+
// TODO: it's a waste to split the text over and over, can
|
|
40
|
+
// we maintain a indicator to the last-read patch?
|
|
41
|
+
var patchStrings = xhr.responseText.split("\n\n")
|
|
42
|
+
|
|
43
|
+
while (patchStrings.length > nextPatchIdx) {
|
|
44
|
+
var nextPatchString = patchStrings[nextPatchIdx]
|
|
45
|
+
var nextPatch = JSON.parse(nextPatchString)
|
|
46
|
+
_this._mergePatch(responseData, nextPatch)
|
|
47
|
+
nextPatchIdx += 1
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
onResponse(responseData)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
xhr.send(JSON.stringify({
|
|
54
|
+
query: queryString,
|
|
55
|
+
variables: variables,
|
|
56
|
+
}))
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
// merge `patch` into `responseData` (destructive)
|
|
60
|
+
_mergePatch: function(responseData, patch) {
|
|
61
|
+
if (patch.path.length === 0) {
|
|
62
|
+
Object.assign(responseData, patch.value)
|
|
63
|
+
} else {
|
|
64
|
+
var targetHash = responseData
|
|
65
|
+
var steps = patch.path.slice(0, patch.path.length - 1)
|
|
66
|
+
var lastKey = patch.path[patch.path.length - 1]
|
|
67
|
+
steps.forEach(function(step) {
|
|
68
|
+
var nextStep = targetHash[step]
|
|
69
|
+
if (nextStep == null) {
|
|
70
|
+
nextStep = targetHash[step] = {}
|
|
71
|
+
}
|
|
72
|
+
targetHash = nextStep
|
|
73
|
+
})
|
|
74
|
+
targetHash[lastKey] = patch.value
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module GraphQL
|
|
3
|
+
module Streaming
|
|
4
|
+
# Send patches by calling `stream.write`
|
|
5
|
+
# Each patch is serialized as JSON and delimited with "\n\n"
|
|
6
|
+
# @example Streaming a response with Rails
|
|
7
|
+
# class ChunkedGraphqlsController < ApplicationController
|
|
8
|
+
# include ActionController::Live
|
|
9
|
+
#
|
|
10
|
+
# def create
|
|
11
|
+
# # initialize the collector with `response.stream`
|
|
12
|
+
# context = {
|
|
13
|
+
# collector: StreamCollector.new(response.stream)
|
|
14
|
+
# }
|
|
15
|
+
#
|
|
16
|
+
# Schema.execute(query_string, variables: variables, context: context)
|
|
17
|
+
#
|
|
18
|
+
# # close the stream when the query is done:
|
|
19
|
+
# response.stream.close
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
class StreamCollector
|
|
23
|
+
DELIMITER = "\n\n"
|
|
24
|
+
|
|
25
|
+
# @param [<#write(String)>] A stream to write patches to
|
|
26
|
+
def initialize(stream)
|
|
27
|
+
@stream = stream
|
|
28
|
+
@first_patch = true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Implement the collector API for DeferredExecution
|
|
32
|
+
def patch(path:, value:)
|
|
33
|
+
patch_string = JSON.dump({path: path, value: value})
|
|
34
|
+
|
|
35
|
+
if @first_patch
|
|
36
|
+
@first_patch = false
|
|
37
|
+
@stream.write(patch_string)
|
|
38
|
+
else
|
|
39
|
+
@stream.write(DELIMITER + patch_string)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: graphql-streaming
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Robert Mosolgo
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2016-08-10 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: graphql
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: bundler
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.11'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.11'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rake
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '10.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '10.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: minitest
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '5.0'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '5.0'
|
|
69
|
+
description: Tools for GraphQL over a long-lived connection
|
|
70
|
+
email:
|
|
71
|
+
- rdmosolgo@gmail.com
|
|
72
|
+
executables: []
|
|
73
|
+
extensions: []
|
|
74
|
+
extra_rdoc_files: []
|
|
75
|
+
files:
|
|
76
|
+
- ".gitignore"
|
|
77
|
+
- ".travis.yml"
|
|
78
|
+
- Gemfile
|
|
79
|
+
- LICENSE.txt
|
|
80
|
+
- README.md
|
|
81
|
+
- Rakefile
|
|
82
|
+
- bin/console
|
|
83
|
+
- bin/setup
|
|
84
|
+
- graphql-streaming.gemspec
|
|
85
|
+
- lib/graphql/streaming.rb
|
|
86
|
+
- lib/graphql/streaming/action_cable_collector.rb
|
|
87
|
+
- lib/graphql/streaming/action_cable_subscriber.rb
|
|
88
|
+
- lib/graphql/streaming/assign_subscription_field.rb
|
|
89
|
+
- lib/graphql/streaming/clients/graphql-streaming/graphql_channel.js
|
|
90
|
+
- lib/graphql/streaming/clients/graphql-streaming/streaming_graphql_client.js
|
|
91
|
+
- lib/graphql/streaming/railtie.rb
|
|
92
|
+
- lib/graphql/streaming/stream_collector.rb
|
|
93
|
+
- lib/graphql/streaming/version.rb
|
|
94
|
+
homepage: https://github.com/rmosolgo/graphql-streaming-ruby
|
|
95
|
+
licenses:
|
|
96
|
+
- MIT
|
|
97
|
+
metadata: {}
|
|
98
|
+
post_install_message:
|
|
99
|
+
rdoc_options: []
|
|
100
|
+
require_paths:
|
|
101
|
+
- lib
|
|
102
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
103
|
+
requirements:
|
|
104
|
+
- - ">="
|
|
105
|
+
- !ruby/object:Gem::Version
|
|
106
|
+
version: '0'
|
|
107
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
108
|
+
requirements:
|
|
109
|
+
- - ">="
|
|
110
|
+
- !ruby/object:Gem::Version
|
|
111
|
+
version: '0'
|
|
112
|
+
requirements: []
|
|
113
|
+
rubyforge_project:
|
|
114
|
+
rubygems_version: 2.5.1
|
|
115
|
+
signing_key:
|
|
116
|
+
specification_version: 4
|
|
117
|
+
summary: Tools for GraphQL over a long-lived connection
|
|
118
|
+
test_files: []
|