graphql-streaming 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|