Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(graphql-ruby-client): support graphql-ws and GraphiQL for Subscription over ActionCable #3708

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion guides/javascript_client/apollo_subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ GraphQL-Ruby's JavaScript client includes four kinds of support for Apollo Clien

## Apollo 2

Apollo 2 is supported by implementing Apollo Links.
Apollo 2 is supported by implementing [Apollo Links](https://www.apollographql.com/docs/react/api/link/introduction/).

## Apollo 2 -- Pusher

Expand Down
48 changes: 48 additions & 0 deletions guides/javascript_client/graphql_ws_and_graphiql_subscription.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
layout: guide
doc_stub: false
search: true
section: JavaScript Client
title: graphql-ws and GraphiQL Subscription
desc: GraphQL subscriptions with graphql-ws client, like GraphiQL
index: 4
---

GraphQL-Ruby's JavaScript client includes 1 kind of support for [`graphql-ws`][graphql-ws], which is used by [GraphiQL][graphiql]:

- [ActionCable](#actioncable)

## ActionCable

`graphql-ruby-client` includes support for subscriptions when integrating [`graphql-ws`][graphql-ws] and [`@rails/actioncable][[rails-actioncable-client-side-component]].

To use it with GraphiQL:

```js
import * as React from 'react';
import ReactDOM from 'react-dom';
import { GraphiQL } from 'graphiql';
import { createGraphiQLFetcher } from '@graphiql/toolkit';
import ActionCableGraphqlWsClient from 'graphql-ruby-client/subscriptions/ActionCableGraphqlWsClient';
import { createConsumer } from '@rails/actioncable';

const cable = createConsumer()
const url = 'https://myschema.com/graphql';

const wsClient = new ActionCableGraphqlWsClient({
cable
// channelName: "GraphqlChannel" // Default
})

const fetcher = createGraphiQLFetcher({
wsClient
});

export const App = () => <GraphiQL fetcher={fetcher} />;

ReactDOM.render(document.getElementByID('graphiql'), <App />);
```

[graphql-ws]: https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md
[graphiql]: https://github.com/graphql/graphiql
[rails-actioncable-client-side-component]: https://guides.rubyonrails.org/action_cable_overview.html#client-side-components
4 changes: 3 additions & 1 deletion javascript_client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@types/zen-observable": "^0.8.2",
"ably": "1.2.6",
"graphql": "^15.0.0",
"graphql-ws": "^5.5.5",
"jest": "^25.0.0",
"nock": "^11.0.0",
"pako": "^2.0.3",
Expand All @@ -41,7 +42,8 @@
},
"peerDependencies": {
"@apollo/client": "^3.3.6",
"graphql": "^14.3.1 || ^15.0.0"
"graphql": "^14.3.1 || ^15.0.0",
"graphql-ws": "^5.5.5"
},
"prettier": {
"semi": false,
Expand Down
95 changes: 95 additions & 0 deletions javascript_client/src/subscriptions/ActionCableGraphqlWsClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import ActionCable from 'actioncable'
import assert from 'assert'
import GraphqlWs from 'graphql-ws'
import { ActionCableUtil } from '../utils/ActionCableUtil'
import GraphqlWsClient from './GraphqlWsClient'

export interface ActionCableGraphqlWsClientOptions {
/** An ActionCable consumer from `.createConsumer` */
cable: ActionCable.Cable
/** ActionCable Channel name. Defaults to "GraphqlChannel" */
channelName: string
/** A generated OperationStoreClient for graphql-pro's OperationStore */
operations?: { getOperationId: Function}
// connectionParams: ConnectionParams
}

interface ChannelNameWithParams extends ActionCable.ChannelNameWithParams {
channel: string
channelId: string
}

class ActionCableGraphqlWsClient extends GraphqlWsClient {
cable: ActionCable.Cable
channel: ActionCable.Channel
cleanup: () => void
sink?: GraphqlWs.Sink
// operations // TODO:

constructor(options: ActionCableGraphqlWsClientOptions) {
super()

this.cable = options.cable
const channelNameWithParams: ChannelNameWithParams = {
channel: options.channelName || 'GraphqlChannel',
channelId: ActionCableUtil.getUniqueChannelId()
}

this.channel = this.cable.subscriptions.create(channelNameWithParams, {
connected: this.action_cable_connected.bind(this),
disconnected: this.action_cable_disconnected.bind(this),
received: this.action_cable_received.bind(this)
})// TODO: support connectionParams like `ActionCableLink.ts` ?
this.cleanup = () => {
this.channel.unsubscribe()
}
}

// TODO: Should we do anything with `event` here ?
on(_event: GraphqlWs.Event) { return () => {} }

subscribe(payload: GraphqlWs.SubscribePayload, sink: GraphqlWs.Sink) {
this.sink = sink
const {
operationName,
query,
variables,
} = payload

const channelParams = {
variables,
operationName,
query
}

this.channel.perform('execute', channelParams)
// TODO: Why another 'send' in `createActionCableHandler` ?
// this.channel.perform('send', channelParams)

return this.cleanup
}

dispose() {
return this.cleanup()
}

private action_cable_connected() {}

private action_cable_disconnected() {}

private action_cable_received(payload: any) {
assert.ok(this.sink) // subscribe() should have been called first, right ?
const result = payload.result

if (result?.errors) {
this.sink.error(result.errors)
} else if (result) {
this.sink.next(result)
}
if (!payload.more) {
this.sink.complete()
}
}
}

export default ActionCableGraphqlWsClient
5 changes: 3 additions & 2 deletions javascript_client/src/subscriptions/ActionCableLink.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ApolloLink, Observable, FetchResult, Operation, NextLink } from "@apollo/client/core"
import { Cable } from "actioncable"
import { print } from "graphql"
import { ActionCableUtil } from "../utils/ActionCableUtil"

type RequestResult = FetchResult<{ [key: string]: any; }, Record<string, any>, Record<string, any>>
type ConnectionParams = object | ((operation: Operation) => object)
Expand All @@ -16,7 +17,7 @@ class ActionCableLink extends ApolloLink {
}) {
super()
this.cable = options.cable
this.channelName = options.channelName || "GraphqlChannel"
this.channelName = options.channelName || ActionCableUtil.DEFAULT_CHANNEL
this.actionName = options.actionName || "execute"
this.connectionParams = options.connectionParams || {}
}
Expand All @@ -25,7 +26,7 @@ class ActionCableLink extends ApolloLink {
// instead, it sends the request to ActionCable.
request(operation: Operation, _next: NextLink): Observable<RequestResult> {
return new Observable((observer) => {
var channelId = Math.round(Date.now() + Math.random() * 100000).toString(16)
var channelId = ActionCableUtil.getUniqueChannelId()
var actionName = this.actionName
var connectionParams = (typeof this.connectionParams === "function") ?
this.connectionParams(operation) : this.connectionParams
Expand Down
6 changes: 3 additions & 3 deletions javascript_client/src/subscriptions/ActionCableSubscriber.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import printer from "graphql/language/printer"
import registry from "./registry"
import { Cable } from "actioncable"
import { ActionCableUtil } from "../utils/ActionCableUtil"

interface ApolloNetworkInterface {
applyMiddlewares: Function
Expand Down Expand Up @@ -28,10 +29,9 @@ class ActionCableSubscriber {
*/
subscribe(request: any, handler: any) {
var networkInterface = this._networkInterface
// unique-ish
var channelId = Math.round(Date.now() + Math.random() * 100000).toString(16)
var channelId = ActionCableUtil.getUniqueChannelId()
var channel = this._cable.subscriptions.create({
channel: "GraphqlChannel",
channel: ActionCableUtil.DEFAULT_CHANNEL,
channelId: channelId,
}, {
// After connecting, send the data over ActionCable
Expand Down
19 changes: 19 additions & 0 deletions javascript_client/src/subscriptions/GraphqlWsClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import GraphqlWs from 'graphql-ws'

class GraphqlWsClient implements GraphqlWs.Client {
on(_event: GraphqlWs.Event) {
console.error('Subclass responsibility')
return () => {}
}

subscribe(_payload: GraphqlWs.SubscribePayload, _sink: GraphqlWs.Sink) {
console.error('Subclass responsibility')
return () => {}
}

dispose() {
console.error('Subclass responsibility')
}
}

export default GraphqlWsClient
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { Cable } from "actioncable"
import { Sink, SubscribePayload } from "graphql-ws"
import ActionCableGraphqlWsClient, { ActionCableGraphqlWsClientOptions } from "../ActionCableGraphqlWsClient"

describe("ActionCableGraphqlWsClient", () => {
let log: any[]
let cable: any
let cableReceived: Function
let options: ActionCableGraphqlWsClientOptions
let query: string
let subscribePayload: SubscribePayload
let sink: Sink

beforeEach(() => {
log = []
cable = {
subscriptions: {
create: function(channelName: string | object, options: {connected: Function, received: Function}) {
let channel = channelName
let params = typeof channel === "object" ? channel : { channel }
let alreadyConnected = false
cableReceived = options.received
let subscription = Object.assign(
Object.create({
perform: function(actionName: string, options: object) {
log.push(["cable perform", { actionName: actionName, options: options }])
},
unsubscribe: function() {
log.push(["cable unsubscribe"])
}
}),
{ params },
options
)

subscription.connected = subscription.connected.bind(subscription)
let received = subscription.received
subscription.received = function(data: any) {
if (!alreadyConnected) {
alreadyConnected = true
subscription.connected()
}
received(data)
}
subscription.__proto__.unsubscribe = subscription.__proto__.unsubscribe.bind(subscription)
return subscription
}
}
}
options = {
cable: (cable as unknown) as Cable,
channelName: 'GraphQLChannel',
operations: undefined
}

query = "subscription { foo { bar } }"

subscribePayload = {
operationName: 'myOperationName',
variables: { a: 1 },
query: query
}

sink = {
next(value) {
log.push(["sink next", value])
},
error(error) {
log.push(["sink error", error])
},
complete() {
log.push(["sink complete"])
}
}
})

it("delegates to the cable", () => {
const client = new ActionCableGraphqlWsClient(options)

client.subscribe(subscribePayload, sink)
cableReceived({ result: { data: null }, more: true })
cableReceived({ result: { data: "data 1" }, more: true })
cableReceived({ result: { data: "data 2" }, more: false })

expect(log).toEqual([
[
"cable perform",
{
actionName: "execute",
options: {
operationName: "myOperationName",
query: "subscription { foo { bar } }",
variables: { a: 1 }
}
}
],
["sink next", { data: null} ],
["sink next", { data: "data 1"} ],
["sink next", { data: "data 2"} ],
["sink complete"],
])
})

it("delegates a manual unsubscribe to the cable", () => {
const client = new ActionCableGraphqlWsClient(options)

client.subscribe(subscribePayload, sink)
cableReceived({ result: { data: null }, more: true })
cableReceived({ result: { data: "data 1" }, more: true })
client.dispose()

expect(log).toEqual([
[
"cable perform",
{
actionName: "execute",
options: {
operationName: "myOperationName",
query: "subscription { foo { bar } }",
variables: { a: 1 }
}
}
],
["sink next", { data: null }],
["sink next", { data: "data 1" }],
["cable unsubscribe"]
])
})
})
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Cable } from "actioncable"
import { ActionCableUtil } from "../utils/ActionCableUtil"

/**
* Create a Relay Modern-compatible subscription handler.
Expand All @@ -14,14 +15,13 @@ interface ActionCableHandlerOptions {

function createActionCableHandler(options: ActionCableHandlerOptions) {
return function (operation: { text: string, name: string}, variables: object, _cacheConfig: object, observer: {onError: Function, onNext: Function, onCompleted: Function}) {
// unique-ish
var channelId = Math.round(Date.now() + Math.random() * 100000).toString(16)
var channelId = ActionCableUtil.getUniqueChannelId()
var cable = options.cable
var operations = options.operations

// Register the subscription by subscribing to the channel
const channel = cable.subscriptions.create({
channel: "GraphqlChannel",
channel: ActionCableUtil.DEFAULT_CHANNEL,
channelId: channelId,
}, {
connected: function() {
Expand Down
Loading