Skip to content

Commit

Permalink
Merge pull request #4724 from rmosolgo/graphiql-subscription-fetchers
Browse files Browse the repository at this point in the history
Add GraphiQL support for subscriptions
  • Loading branch information
rmosolgo authored Dec 7, 2023
2 parents 52ec07b + b247fa5 commit 01e9ff2
Show file tree
Hide file tree
Showing 11 changed files with 647 additions and 3 deletions.
94 changes: 94 additions & 0 deletions guides/javascript_client/graphiql_subscriptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
---
layout: guide
doc_stub: false
search: true
section: JavaScript Client
title: GraphiQL Subscriptions
desc: Testing GraphQL subscriptions in the GraphiQL IDE
index: 5
---

After setting up your server, you can integrate subscriptions into [GraphiQL](https://github.com/graphql/graphiql/tree/main/packages/graphiql#readme), the in-browser GraphQL IDE.

## Adding GraphiQL to your app

To get started, make a page for rendering GraphiQL, for example:

```html
<!-- views/graphiqls/show.html -->
<div id="root" style="height: 100vh;"></div>
```

Then, install GraphiQL (eg, `yarn add graphiql`) and add JavaScript code to import GraphiQL and render it on your page:

```js
import { GraphiQL } from 'graphiql';
import React from 'react';
import { createRoot } from 'react-dom/client';
import 'graphiql/graphiql.css';
import { createGraphiQLFetcher } from '@graphiql/toolkit';

const fetcher = createGraphiQLFetcher({ url: '/graphql' });
const root = createRoot(document.getElementById('root'));
root.render(<GraphiQL fetcher={fetcher}/>);
```

After that, you should be able to load the page in your app and see the GraphiQL editor.

## Ably

To integrate {% internal_link "Ably subscriptions", "subscriptions/ably_implementation" %}, use `createAblyFetcher`, for example:

```js
import Ably from "ably"
import createAblyFetcher from 'graphql-ruby-client/subscriptions/createAblyFetcher';

// Initialize a client
// the key must have "subscribe" and "presence" permissions
const ably = new Ably.Realtime({ key: "your.application.key" })

// Initialize a new fetcher and pass it to GraphiQL below
var fetcher = createAblyFetcher({ ably: ably, url: "/graphql" })
const root = createRoot(document.getElementById('root'));
root.render(<GraphiQL fetcher={fetcher} />);
```

Under the hood, it will use `window.fetch` to send GraphQL operations to the server, then listen for `X-Subscription-ID` headers in responses. To customize its HTTP requests, you can pass a `fetchOptions:` object or a custom `fetch:` function to `createAblyFetcher({ ... })`.

## Pusher

To integrate {% internal_link "Pusher subscriptions", "subscriptions/pusher_implementation" %}, use `createPusherFetcher`, for example:

```js
import Pusher from "pusher-js"
import createPusherFetcher from 'graphql-ruby-client/subscriptions/createPusherFetcher';

// Initialize a client
const pusher = new Pusher("your-app-key", { cluster: "your-cluster" })

// Initialize a new fetcher and pass it to GraphiQL below
var fetcher = createPusherFetcher({ pusher: pusher, url: "/graphql" })
const root = createRoot(document.getElementById('root'));
root.render(<GraphiQL fetcher={fetcher} />);
```

Under the hood, it will use `window.fetch` to send GraphQL operations to the server, then listen for `X-Subscription-ID` headers in responses. To customize its HTTP requests, you can pass a `fetchOptions:` object or a custom `fetch:` function to `createPusherFetcher({ ... })`.

## ActionCable

To integrate {% internal_link "ActionCable subscriptions", "subscriptions/action_cable_implementation" %}, use `createActionCableFetcher`, for example:

```js
import { createConsumer } from "@rails/actioncable"
import createActionCableFetcher from 'graphql-ruby-client/subscriptions/createActionCableFetcher';

// Initialize a client
const actionCable = createConsumer()

// Initialize a new fetcher and pass it to GraphiQL below
var fetcher = createActionCableFetcher({ consumer: actionCable, url: "/graphql" })
const root = createRoot(document.getElementById('root'));
root.render(<GraphiQL fetcher={fetcher} />);
```

Under the hood, it will split traffic: it will send `subscription { ... }` operations via ActionCable and send queries and mutations via HTTP `POST` using `window.fetch`. To customize its HTTP requests, you can pass a `fetchOptions:` object or a custom `fetch:` function to `createPusherFetcher({ ... })`.
6 changes: 5 additions & 1 deletion guides/subscriptions/ably_implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,4 +313,8 @@ To receive webhooks in development, you can [use ngrok](https://www.ably.io/tuto

## Client configuration

Install the [Ably JS client](https://github.com/ably/ably-js) then see docs for {% internal_link "Apollo Client", "/javascript_client/apollo_subscriptions" %}.
Install the [Ably JS client](https://github.com/ably/ably-js) then see docs for:

- {% internal_link "Apollo Client", "/javascript_client/apollo_subscriptions" %}
- {% internal_link "Relay Modern", "/javascript_client/relay_subscriptions" %}.
- {% internal_link "GraphiQL", "/javascript_client/graphiql_subscriptions" %}
6 changes: 5 additions & 1 deletion guides/subscriptions/action_cable_implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ index: 4

To get started, see examples in the API docs: {{ "GraphQL::Subscriptions::ActionCableSubscriptions" | api_doc }}.

See client usage for {% internal_link "Apollo Client", "/javascript_client/apollo_subscriptions" %} or {% internal_link "Relay Modern", "/javascript_client/relay_subscriptions" %}.
See client usage for:

- {% internal_link "Apollo Client", "/javascript_client/apollo_subscriptions" %}
- {% internal_link "Relay Modern", "/javascript_client/relay_subscriptions" %}.
- {% internal_link "GraphiQL", "/javascript_client/graphiql_subscriptions" %}
7 changes: 6 additions & 1 deletion guides/subscriptions/pusher_implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,4 +296,9 @@ To receive Pusher's webhooks in development, Pusher [suggests using ngrok](https

## Client configuration

Install the [Pusher JS client](https://github.com/pusher/pusher-js) then see docs for {% internal_link "Apollo Client", "/javascript_client/apollo_subscriptions" %} or {% internal_link "Relay Modern", "/javascript_client/relay_subscriptions" %}.
Install the [Pusher JS client](https://github.com/pusher/pusher-js) then see docs for:

- {% internal_link "Apollo Client", "/javascript_client/apollo_subscriptions" %}
- {% internal_link "Relay Modern", "/javascript_client/relay_subscriptions" %}
- {% internal_link "GraphiQL", "/javascript_client/graphiql_subscriptions" %}
- {% internal_link "urql", "/javascript_client/urql_subscriptions" %}
1 change: 1 addition & 0 deletions javascript_client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"pako": "^2.0.3",
"prettier": "^1.19.1",
"pusher-js": "^7.0.3",
"@rails/actioncable": "^7.0.0",
"relay-runtime": "11.0.2",
"ts-jest": "^29.0.0",
"typescript": "5.1.6",
Expand Down
105 changes: 105 additions & 0 deletions javascript_client/src/subscriptions/__tests__/createAblyFetcherTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import createAblyFetcher from "../createAblyFetcher"
import { Realtime } from "ably"

function createAbly() {
const _channels: {[key: string]: any } = {}

const ably = {
_channels: _channels,
channels: {
get(channelName: string) {
return _channels[channelName] ||= {
_listeners: [] as [string, Function][],
name: channelName,
presence: {
enterClient(_clientName: string, _status: string) {},
leaveClient(_clientName: string) {},
},
detach(callback: Function) {
callback()
},
subscribe(eventName: string, callback: Function) {
this._listeners.push([eventName, callback])
},
unsubscribe(){}
}
},
release(channelName: string) {
delete _channels[channelName]
}
},
__testTrigger(channelName: string, eventName: string, data: any) {
const channel = this.channels.get(channelName)
const handler = channel._listeners.find((l: any) => l[0] == eventName)
if (handler) {
handler[1](data)
}
}
}

return ably
}


describe("createAblyFetcher", () => {
it("yields updates for subscriptions", () => {
const ably = createAbly()

const fetchLog: any[] = []
const dummyFetch = function(url: string, fetchArgs: any) {
fetchLog.push([url, fetchArgs.customOpt])
const dummyResponse = {
json: () => {
return {
data: {
hi: "First response"
}
}
},
headers: {
get() {
return fetchArgs.body.includes("subscription") ? "abcd" : null
}
}
}
return Promise.resolve(dummyResponse)
}

const fetcher = createAblyFetcher({
ably: (ably as unknown) as Realtime,
url: "/graphql",
fetch: ((dummyFetch as unknown) as typeof fetch),
fetchOptions: {customOpt: true}
})

const result = fetcher({
variables: {},
operationName: "hello",
body: "subscription hello { hi }"
}, {})

return result.next().then((res) => {
expect(res.value.data.hi).toEqual("First response")
expect(fetchLog).toEqual([["/graphql", true]])
}).then(() => {
const promise = result.next().then((res2) => {
expect(res2).toEqual({ value: { data: { hi: "Bonjour" } }, done: false })
})

ably.__testTrigger("abcd", "update", { data: { result: { data: { hi: "Bonjour" } } } })

return promise.then(() => {
// Test non-subscriptions too:
expect(Object.keys(ably._channels)).toEqual(["abcd"])
const queryResult = fetcher({ variables: {}, operationName: null, body: "{ __typename }"}, {})
return queryResult.next().then((res) => {
expect(res.value.data).toEqual({ hi: "First response"})
return queryResult.next().then((res2) => {
expect(res2.done).toEqual(true)
expect(ably._channels).toEqual({})
})
})
})
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import createActionCableFetcher from "../createActionCableFetcher"
import type { Consumer } from "@rails/actioncable"
import { parse } from "graphql"

describe("createActionCableFetcherTest", () => {
it("yields updates for subscriptions", () => {
var handlers: any
var log: [string, any][]= []

var dummyActionCableConsumer = {
subscriptions: {
create: (_conn: any, newHandlers: any) => {
handlers = newHandlers
return {
perform: (evt: string, data: any) => {
log.push([evt, data])
}
}
}
}
}

const fetchLog: any[] = []
const dummyFetch = function(url: string, fetchArgs: any) {
fetchLog.push([url, fetchArgs.custom])
return Promise.resolve({ json: () => { {} } })
}

var options = {
consumer: (dummyActionCableConsumer as unknown) as Consumer,
url: "/graphql",
fetch: dummyFetch as typeof fetch,
fetchOptions: {
custom: true,
}
}

var fetcher = createActionCableFetcher(options)


const queryStr = "subscription listen { update { message } }"
const doc = parse(queryStr)

const res = fetcher({ operationName: "listen", query: queryStr, variables: {}}, { documentAST: doc })
const promise = res.next().then((result) => {

handlers.connected() // trigger the GraphQL send

expect(result).toEqual({ value: { data: "hello" } , done: false })
expect(fetchLog).toEqual([])
expect(log).toEqual([
["execute", { operationName: "listen", query: queryStr, variables: {} }],
])
})

handlers.received({ result: { data: "hello" } }) // simulate an update

return promise.then(() => {
let res2 = fetcher({ operationName: null, query: "{ __typename } ", variables: {}}, {})
const promise2 = res2.next().then(() => {
expect(fetchLog).toEqual([["/graphql", true]])
})
return promise2
})
})
})
Loading

0 comments on commit 01e9ff2

Please sign in to comment.