Skip to content

Commit 39b86a9

Browse files
committed
Move ActionCable code to SubscriptionExchange, add tests
1 parent 93fd145 commit 39b86a9

File tree

4 files changed

+194
-135
lines changed

4 files changed

+194
-135
lines changed

guides/javascript_client/urql_subscriptions.md

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ desc: GraphQL subscriptions with GraphQL-Ruby and urql
88
index: 4
99
---
1010

11-
GraphQL-Ruby currently supports using `urql` with the {% internal_link "ActionCable", "/subscriptions/action_cable_implementation" %} and {% internal_link "Pusher implementation", "/subscriptions/pusher_implementation" %}. For example:
11+
GraphQL-Ruby currently supports using `urql` with the {% internal_link "ActionCable", "/subscriptions/action_cable_implementation" %} and {% internal_link "Pusher implementation", "/subscriptions/pusher_implementation" %}.
1212

1313
## Pusher
1414

@@ -35,25 +35,20 @@ const client = new Client({
3535

3636
```js
3737
import { createConsumer } from "@rails/actioncable";
38-
import createUrqlActionCableSubscription from "graphql-ruby-client/subscriptions/createUrqlActionCableSubscription";
38+
import SubscriptionExchange from "graphql-ruby-client/subscriptions/SubscriptionExchange"
3939

4040
const actionCable = createConsumer('ws://127.0.0.1:3000/cable');
41-
const forwardToActionCableExchange = createUrqlActionCableSubscription.create({ consumer: actionCable })
41+
const forwardToActionCable = SubscriptionExchange.create({ consumer: actionCable })
4242

4343
const client = new Client({
44-
url: 'http://127.0.0.1:3000/graphql',
44+
url: '/graphql',
4545
exchanges: [
46-
cacheExchange, fetchExchange, subscriptionExchange({
47-
forwardSubscription: operation => forwardToActionCableExchange(operation)
48-
})
49-
]
46+
...defaultExchanges,
47+
subscriptionExchange({
48+
forwardSubscription: forwardToActionCable
49+
}),
50+
],
5051
});
51-
52-
const App = () => (
53-
<Provider value={client}>
54-
// ... your app code here
55-
</Provider>
56-
);
5752
```
5853

5954
Want to use `urql` with another subscription backend? Please {% open_an_issue "Using urql with ..." %}.
Lines changed: 118 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,94 @@
11
import Pusher from "pusher-js"
22
import Urql from "urql"
3+
import { Consumer, Subscription } from "@rails/actioncable"
34

45
type ForwardCallback = (...args: any[]) => void
56

67
const SubscriptionExchange = {
7-
create(options: { pusher: Pusher }) {
8-
const pusher = options.pusher
9-
return function(operation: Urql.Operation) {
10-
// urql will call `.subscribed` on the returned object:
11-
// https://github.com/FormidableLabs/urql/blob/f89cfd06d9f14ae9cb3be10b21bd5cbd12ca275c/packages/core/src/exchanges/subscription.ts#L68-L73
12-
// https://github.com/FormidableLabs/urql/blob/f89cfd06d9f14ae9cb3be10b21bd5cbd12ca275c/packages/core/src/exchanges/subscription.ts#L82-L97
13-
return {
14-
subscribe: ({next, error, complete}: { next: ForwardCallback, error: ForwardCallback, complete: ForwardCallback}) => {
15-
// Somehow forward the operation to be POSTed to the server,
16-
// I don't see an option for passing this on to the `fetchExchange`
17-
const fetchBody = JSON.stringify({
18-
query: operation.query,
19-
variables: operation.variables,
20-
})
21-
var pusherChannelName: string
22-
const subscriptionId = "" + operation.key
23-
var fetchOptions = operation.context.fetchOptions
24-
if (typeof fetchOptions === "function") {
25-
fetchOptions = fetchOptions()
26-
} else if (fetchOptions == null) {
27-
fetchOptions = {}
28-
}
8+
create(options: { pusher?: Pusher, consumer?: Consumer, channelName?: string }) {
9+
if (options.pusher) {
10+
return createPusherSubscription(options.pusher)
11+
} else if (options.consumer) {
12+
return createUrqlActionCableSubscription(options.consumer, options?.channelName)
13+
} else {
14+
throw new Error("Either `pusher: ...` or `consumer: ...` is required.")
15+
}
16+
}
17+
}
2918

30-
const headers = {
31-
...(fetchOptions.headers),
32-
...{
33-
'Content-Type': 'application/json',
34-
'X-Subscription-ID': subscriptionId
35-
}
36-
}
3719

38-
const defaultFetchOptions = { method: "POST" }
39-
const mergedFetchOptions = {
40-
...defaultFetchOptions,
41-
...fetchOptions,
42-
body: fetchBody,
43-
headers: headers,
20+
function createPusherSubscription(pusher: Pusher) {
21+
return function(operation: Urql.Operation) {
22+
// urql will call `.subscribed` on the returned object:
23+
// https://github.com/FormidableLabs/urql/blob/f89cfd06d9f14ae9cb3be10b21bd5cbd12ca275c/packages/core/src/exchanges/subscription.ts#L68-L73
24+
// https://github.com/FormidableLabs/urql/blob/f89cfd06d9f14ae9cb3be10b21bd5cbd12ca275c/packages/core/src/exchanges/subscription.ts#L82-L97
25+
return {
26+
subscribe: ({next, error, complete}: { next: ForwardCallback, error: ForwardCallback, complete: ForwardCallback}) => {
27+
// Somehow forward the operation to be POSTed to the server,
28+
// I don't see an option for passing this on to the `fetchExchange`
29+
const fetchBody = JSON.stringify({
30+
query: operation.query,
31+
variables: operation.variables,
32+
})
33+
var pusherChannelName: string
34+
const subscriptionId = "" + operation.key
35+
var fetchOptions = operation.context.fetchOptions
36+
if (typeof fetchOptions === "function") {
37+
fetchOptions = fetchOptions()
38+
} else if (fetchOptions == null) {
39+
fetchOptions = {}
40+
}
41+
42+
const headers = {
43+
...(fetchOptions.headers),
44+
...{
45+
'Content-Type': 'application/json',
46+
'X-Subscription-ID': subscriptionId
4447
}
45-
const fetchFn = operation.context.fetch || fetch
46-
fetchFn(operation.context.url, mergedFetchOptions)
47-
.then((fetchResult) => {
48-
// Get the server-provided subscription ID
49-
pusherChannelName = fetchResult.headers.get("X-Subscription-ID") as string
50-
// Set up a subscription to Pusher, forwarding updates to
51-
// the `next` function provided by urql
52-
const pusherChannel = pusher.subscribe(pusherChannelName)
53-
pusherChannel.bind("update", (payload: {result: object, more: boolean}) => {
54-
// Here's an update to this subscription,
55-
// pass it on:
56-
if (payload.result) {
57-
next(payload.result)
58-
}
59-
// If the server signals that this is the end,
60-
// then unsubscribe the client:
61-
if (!payload.more) {
62-
complete()
63-
}
64-
})
65-
// Continue processing the initial result for the subscription
66-
return fetchResult.json()
67-
})
68-
.then((jsonResult) => {
69-
// forward the initial result to urql
70-
next(jsonResult)
48+
}
49+
50+
const defaultFetchOptions = { method: "POST" }
51+
const mergedFetchOptions = {
52+
...defaultFetchOptions,
53+
...fetchOptions,
54+
body: fetchBody,
55+
headers: headers,
56+
}
57+
const fetchFn = operation.context.fetch || fetch
58+
fetchFn(operation.context.url, mergedFetchOptions)
59+
.then((fetchResult) => {
60+
// Get the server-provided subscription ID
61+
pusherChannelName = fetchResult.headers.get("X-Subscription-ID") as string
62+
// Set up a subscription to Pusher, forwarding updates to
63+
// the `next` function provided by urql
64+
const pusherChannel = pusher.subscribe(pusherChannelName)
65+
pusherChannel.bind("update", (payload: {result: object, more: boolean}) => {
66+
// Here's an update to this subscription,
67+
// pass it on:
68+
if (payload.result) {
69+
next(payload.result)
70+
}
71+
// If the server signals that this is the end,
72+
// then unsubscribe the client:
73+
if (!payload.more) {
74+
complete()
75+
}
7176
})
72-
.catch(error)
77+
// Continue processing the initial result for the subscription
78+
return fetchResult.json()
79+
})
80+
.then((jsonResult) => {
81+
// forward the initial result to urql
82+
next(jsonResult)
83+
})
84+
.catch(error)
7385

74-
// urql will call `.unsubscribe()` if it's returned here:
75-
// https://github.com/FormidableLabs/urql/blob/f89cfd06d9f14ae9cb3be10b21bd5cbd12ca275c/packages/core/src/exchanges/subscription.ts#L102
76-
return {
77-
unsubscribe: () => {
78-
// When requested by urql, disconnect from this channel
79-
pusherChannelName && pusher.unsubscribe(pusherChannelName)
80-
}
86+
// urql will call `.unsubscribe()` if it's returned here:
87+
// https://github.com/FormidableLabs/urql/blob/f89cfd06d9f14ae9cb3be10b21bd5cbd12ca275c/packages/core/src/exchanges/subscription.ts#L102
88+
return {
89+
unsubscribe: () => {
90+
// When requested by urql, disconnect from this channel
91+
pusherChannelName && pusher.unsubscribe(pusherChannelName)
8192
}
8293
}
8394
}
@@ -86,4 +97,42 @@ const SubscriptionExchange = {
8697
}
8798

8899

100+
101+
function createUrqlActionCableSubscription(consumer: Consumer, channelName: string = "GraphqlChannel") {
102+
return function(operation: Urql.Operation) {
103+
let subscription: Subscription | null = null
104+
// urql will call `.subscribed` on the returned object:
105+
// https://github.com/FormidableLabs/urql/blob/f89cfd06d9f14ae9cb3be10b21bd5cbd12ca275c/packages/core/src/exchanges/subscription.ts#L68-L73
106+
// https://github.com/FormidableLabs/urql/blob/f89cfd06d9f14ae9cb3be10b21bd5cbd12ca275c/packages/core/src/exchanges/subscription.ts#L82-L97
107+
return {
108+
subscribe: ({next, error, complete}: { next: ForwardCallback, error: ForwardCallback, complete: ForwardCallback}) => {
109+
subscription = consumer.subscriptions.create(channelName, {
110+
connected() {
111+
console.log(subscription)
112+
subscription?.perform("execute", { query: operation.query, variables: operation.variables })
113+
},
114+
received(data: any) {
115+
if (data?.result?.errors) {
116+
error(data.errors)
117+
}
118+
if (data?.result?.data) {
119+
next(data.result)
120+
}
121+
if (!data.more) {
122+
complete()
123+
}
124+
}
125+
} as any)
126+
// urql will call `.unsubscribe()` if it's returned here:
127+
// https://github.com/FormidableLabs/urql/blob/f89cfd06d9f14ae9cb3be10b21bd5cbd12ca275c/packages/core/src/exchanges/subscription.ts#L102
128+
return {
129+
unsubscribe: () => {
130+
subscription?.unsubscribe()
131+
}
132+
}
133+
}
134+
}
135+
}
136+
}
137+
89138
export default SubscriptionExchange

javascript_client/src/subscriptions/__tests__/SubscriptionExchangeTest.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import Pusher from "pusher-js"
33
import Urql from "urql"
44
import {parse} from "graphql"
55
import { nextTick } from "process"
6+
import { Consumer } from "@rails/actioncable"
67

78
type MockChannel = {
89
bind: (action: string, handler: Function) => void,
910
}
1011

11-
describe("SubscriptionExchange", () => {
12+
describe("SubscriptionExchange with Pusher", () => {
1213
var channelName = "1234"
1314
var log: any[]
1415
var pusher: any
@@ -99,3 +100,68 @@ describe("SubscriptionExchange", () => {
99100

100101
})
101102
})
103+
104+
describe("SubscriptionExchange with ActionCable", () => {
105+
it("calls through to handlers", () => {
106+
var handlers: any
107+
var log: [string, any][]= []
108+
109+
var dummyActionCableConsumer = {
110+
subscriptions: {
111+
create: (channelName: string, newHandlers: any) => {
112+
log.push(["create", channelName])
113+
handlers = newHandlers
114+
return {
115+
perform: (evt: string, data: any) => {
116+
log.push([evt, data])
117+
},
118+
unsubscribe: () => {
119+
log.push(["unsubscribed", null])
120+
}
121+
}
122+
}
123+
}
124+
}
125+
126+
var options = {
127+
consumer: (dummyActionCableConsumer as unknown) as Consumer,
128+
channelName: "CustomChannel"
129+
}
130+
131+
var exchange = SubscriptionExchange.create(options);
132+
var parsedQuery = parse("{ foo { bar } }")
133+
var operation = {
134+
query: parsedQuery,
135+
variables: {},
136+
context: {
137+
url: "/graphql",
138+
requestPolicy: "network-only",
139+
},
140+
kind: "subscription",
141+
} as Urql.Operation
142+
143+
var subscriber = exchange(operation)
144+
const next = (data: any) => { log.push(["next", data]) }
145+
const error = (err: any) => { log.push(["error", err]) }
146+
const complete = (data: any) => { log.push(["complete", data]) }
147+
const subscription = subscriber.subscribe({ next, error, complete })
148+
149+
150+
return new Promise((resolve, _reject) => {
151+
nextTick(() => {
152+
handlers.connected() // trigger the GraphQL send
153+
handlers.received({ result: { data: { a: "1" } }, more: false })
154+
subscription.unsubscribe()
155+
const expectedLog = [
156+
["create", "CustomChannel"],
157+
["execute", { query: parsedQuery, variables: {} }],
158+
["next", { data: { a: "1" } }],
159+
["complete", undefined],
160+
["unsubscribed", null],
161+
]
162+
expect(log).toEqual(expectedLog)
163+
resolve(true)
164+
})
165+
})
166+
})
167+
})

javascript_client/src/subscriptions/createUrqlActionCableSubscription.ts

Lines changed: 0 additions & 51 deletions
This file was deleted.

0 commit comments

Comments
 (0)