graphql-streaming 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
data/.travis.yml ADDED
@@ -0,0 +1,4 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.3.0
4
+ before_install: gem install bundler -v 1.11.2
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in graphql-streaming.gemspec
4
+ gemspec
5
+ gem "graphql", github: "rmosolgo/graphql", branch: "defer-directive"
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
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList['test/**/*_test.rb']
8
+ end
9
+
10
+ task :default => :spec
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,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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,9 @@
1
+ module GraphQL
2
+ module Streaming
3
+ class Railtie < Rails::Railtie
4
+ config.before_initialize do |app|
5
+ app.config.assets.paths << File.expand_path("../clients", __FILE__)
6
+ end
7
+ end
8
+ end
9
+ end
@@ -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
@@ -0,0 +1,5 @@
1
+ module Graphql
2
+ module Streaming
3
+ VERSION = "0.1.0"
4
+ end
5
+ 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: []