Skip to content

Commit

Permalink
Feature/subscription support (ember-graphql#173)
Browse files Browse the repository at this point in the history
Built-in support for subscriptions.
  • Loading branch information
coladarci authored and josemarluedke committed Mar 9, 2019
1 parent 7b07133 commit d113142
Show file tree
Hide file tree
Showing 10 changed files with 386 additions and 13 deletions.
115 changes: 115 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,111 @@ See the [detailed query manager docs][query-manager-api] for more details on
usage, or the [Apollo service API][apollo-service-api] if you need to use
the service directly.

### GraphQL Subscriptions

GQL Subscriptions allow a client to subscribe to specific queries they are interested in tracking. The syntax for doing this is similar to `query` / `watchQuery`, but there are a few main differences:

- you must define a `subscription` (versus a `query` or `mutation`)
- because subscriptions are async by nature, you have to listen for these events and act accordingly.
- subscriptions require websockets, so must configure your `link` accordingly

#### Creating your subscription

`app/gql/subscriptions/new-human.graphql`

```graphql
subscription {
newHuman() {
name
}
}
```

#### Subscribing from inside a route

`app/routes/some-route.js`

```js
import Route from '@ember/routing/route';
import { RouteQueryManager } from 'ember-apollo-client';
import query from 'app/gql/subscriptions/new-human';

export default Route.extend(RouteQueryManager, {
model() {
return this.get('apollo')
.subscribe({ query }, 'human');
},

setupController(controller, model) {
model.on('event', event => alert(`${event.name} was just born!`));
},
});
```

The big advantage of using the `RouteQueryManager` is that when you navigate away from this route, all subscriptions created will be terminated. That said, if you want to manually unsubscribe (or are not using the `RouteQueryManager`) `subscription.unsubscribe()` will do the trick.

**Enabling Websockets**

While this library should work w/ any back-end implementation, here's an example with Authenticated [Phoenix](https://github.com/phoenixframework/phoenix) + [Absinthe](https://github.com/absinthe-graphql/absinthe):

`my-app/services/apollo.js`
```js
import ApolloService from 'ember-apollo-client/services/apollo';
import { Socket } from 'phoenix';
import { inject as service } from "@ember/service";
import { createAbsintheSocketLink } from '@absinthe/socket-apollo-link';
import AbsintheSocket from '@absinthe/socket';
import { computed } from '@ember/object';


export default ApolloService.extend({
session: service(),

link: computed(function () {
const socket = new Socket("ws://socket-url", {
params: { token: this.get('session.token') },
});
const absintheSocket = AbsintheSocket.create(socket);

return createAbsintheSocketLink(absintheSocket);
}),
});

```
Note: This will switch **all** gql communication to use websockets versus `http`. If you want to conditionally use websockets for only subscriptions (a common pattern) this is where [Apollo Link Composition](https://www.apollographql.com/docs/link/composition.html) comes in. Specifically, the `split` function is what we're after (note we are using [apollo-utilities](https://www.npmjs.com/package/apollo-utilities), a helpful `npm` package):

`my-app/services/apollo.js`
```js
import { computed } from '@ember/object';
import ApolloService from 'ember-apollo-client/services/apollo';
import { split } from 'apollo-link';
import { getMainDefinition } from 'apollo-utilities';
import { createAbsintheSocketLink } from '@absinthe/socket-apollo-link';
import AbsintheSocket from '@absinthe/socket';

export default ApolloService.extend({
session: service(),

link: computed(function () {
const httpLink = this._super(...arguments);
const socket = new Socket("ws://socket-url", {
params: { token: this.get('session.token') },
});
const absintheSocket = AbsintheSocket.create(socket);

return split(
({ query }) => {
const { kind, operation } = getMainDefinition(query);

return kind === 'OperationDefinition' && operation === 'subscription';
},
socketLink,
httpLink
);
}),
```
### Mutations and Fragments
You can perform a mutation using the `mutate` method. You can also use GraphQL
Expand Down Expand Up @@ -257,7 +362,17 @@ export default Route.extend({
can be used to resolve beneath the root.
The query manager will automatically unsubscribe from this object.
* `subscribe(options, resultKey)`: This calls the
[`ApolloClient.subscribe`][subscribe] method. It returns a promise that
resolves with an `EmberApolloSubscription`. You can use this object in a few ways to keep
track of your subscription:
- emberApolloSubscription.on('event', event => do_something_with(event)); // manually act on event
- emberApolloSubscription.get('lastEvent'); // return the most recently received event data
As before, the `resultKey` can be used to resolve beneath the root.
The query manager will automatically unsubscribe from this object. If you want to manually
unsubscribe, you can do so with `emberApolloSubscription.apolloUnsubscribe();`
* `query(options, resultKey)`: This calls the
[`ApolloClient.query`](https://www.apollographql.com/docs/react/api/apollo-client.html#ApolloClient.query)
method. It returns a promise that resolves with the raw POJO data that the
Expand Down
8 changes: 4 additions & 4 deletions addon/-private/mixins/base-query-manager.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { inject as service } from '@ember/service';
import Mixin from '@ember/object/mixin';
import { computed } from '@ember/object';

export default Mixin.create({
apolloService: service('apollo'),
init() {
this._super(...arguments);
this.set('apollo', this.get('apolloService').createQueryManager());
},
apollo: computed('apolloService', function() {
return this.get('apolloService').createQueryManager();
}),
});
19 changes: 19 additions & 0 deletions addon/apollo/query-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,25 @@ export default EmberObject.extend({
return this.get('apollo').managedWatchQuery(this, opts, resultKey);
},

/**
* Executes a `subscribe` on the Apollo service.
*
* This subscription is tracked by the QueryManager and will be unsubscribed
* (and no longer updated with new data) when unsubscribeAll() is called.
*
* The Promise will contain a Subscription object which will contain events
* as they come in. It will also trigger `event` messages which can be listened for.
*
* @method subscribe
* @param {!Object} opts The query options used in the Apollo Client watchQuery.
* @param {String} resultKey The key that will be returned from the resulting response data. If null or undefined, the entire response data will be returned.
* @return {!Promise}
* @public
*/
subscribe(opts, resultKey) {
return this.get('apollo').managedSubscribe(this, opts, resultKey);
},

/**
* Tracks a subscription in the list of active subscriptions, which will all be
* cancelled when `unsubcribeAll` is called.
Expand Down
107 changes: 98 additions & 9 deletions addon/services/apollo.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,49 @@ import QueryManager from 'ember-apollo-client/apollo/query-manager';
import copyWithExtras from 'ember-apollo-client/utils/copy-with-extras';
import { registerWaiter } from '@ember/test';
import fetch from 'fetch';
import Evented from '@ember/object/evented';

const EmberApolloSubscription = EmberObject.extend(Evented, {
lastEvent: null,

apolloUnsubscribe() {
this.get('_apolloClientSubscription').unsubscribe();
},

_apolloClientSubscription: null,

_onNewData(newData) {
this.set('lastEvent', newData);
this.trigger('event', newData);
},
});

function extractNewData(resultKey, { data, loading }) {
if (loading && isNone(data)) {
// This happens when the cache has no data and the data is still loading
// from the server. We don't want to resolve the promise with empty data
// so we instead just bail out.
//
// See https://github.com/bgentry/ember-apollo-client/issues/45
return null;
}
let keyedData = isNone(resultKey) ? data : data && get(data, resultKey);

return copyWithExtras(keyedData || {}, [], []);
}

function newDataFunc(observable, resultKey, resolve, mergedProps = {}) {
let obj;
mergedProps[apolloObservableKey] = observable;

return ({ data, loading }) => {
if (loading && data === undefined) {
// This happens when the cache has no data and the data is still loading
// from the server. We don't want to resolve the promise with empty data
// so we instead just bail out.
//
// See https://github.com/bgentry/ember-apollo-client/issues/45
return newData => {
let dataToSend = extractNewData(resultKey, newData);

if (dataToSend === null) {
// see comment in extractNewData
return;
}
let keyedData = isNone(resultKey) ? data : data && get(data, resultKey);
let dataToSend = copyWithExtras(keyedData || {}, [], []);

if (isNone(obj)) {
if (isArray(dataToSend)) {
obj = A(dataToSend);
Expand Down Expand Up @@ -197,6 +224,49 @@ export default Service.extend({
);
},

/**
* Executes a `subscribe` on the Apollo client. If this subscription receives
* data, the resolved object will be updated with the new data.
*
* When using this method, it is important to call `apolloUnsubscribe()` on
* the resolved data when the route or component is torn down. That tells
* Apollo to stop trying to send updated data to a non-existent listener.
*
* @method subscribe
* @param {!Object} opts The query options used in the Apollo Client subscribe.
* @param {String} resultKey The key that will be returned from the resulting response data. If null or undefined, the entire response data will be returned.
* @return {!Promise}
* @public
*/
subscribe(opts, resultKey = null) {
const observable = this.get('client').subscribe(opts);

const obj = EmberApolloSubscription.create();

return this._waitFor(
new RSVP.Promise((resolve, reject) => {
let subscription = observable.subscribe({
next: newData => {
let dataToSend = extractNewData(resultKey, newData);
if (dataToSend === null) {
// see comment in extractNewData
return;
}

run(() => obj._onNewData(dataToSend));
},
error(e) {
reject(e);
},
});

obj.set('_apolloClientSubscription', subscription);

resolve(obj);
})
);
},

/**
* Executes a single `query` on the Apollo client. The resolved object will
* never be updated and does not have to be unsubscribed.
Expand Down Expand Up @@ -253,6 +323,25 @@ export default Service.extend({
);
},

/**
* Executes a `subscribe` on the Apollo client and tracks the resulting
* subscription on the provided query manager.
*
* @method managedSubscribe
* @param {!Object} manager A QueryManager that should track this active subscribe.
* @param {!Object} opts The query options used in the Apollo Client subscribe.
* @param {String} resultKey The key that will be returned from the resulting response data. If null or undefined, the entire response data will be returned.
* @return {!Promise}
* @private
*/
managedSubscribe(manager, opts, resultKey = null) {
return this.subscribe(opts, resultKey).then(obj => {
manager.trackSubscription(obj._apolloClientSubscription);

return obj;
});
},

createQueryManager() {
return QueryManager.create({ apollo: this });
},
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/build/graphql-filter-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import gql from 'graphql-tag';

import testFragment from './test-fragment';
import testQuery from './test-query';
import testSubscription from './test-subscription';

module('Unit | graphql-filter', function() {
function testCompilation(description, { actual, expected }) {
Expand All @@ -23,6 +24,17 @@ module('Unit | graphql-filter', function() {
`,
});

testCompilation('simple subscription compilation', {
actual: testSubscription,
expected: gql`
subscription TestSubscription {
subject {
name
}
}
`,
});

testCompilation('compilation with #import references', {
actual: testQuery,
expected: gql`
Expand Down
5 changes: 5 additions & 0 deletions tests/unit/build/test-subscription.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
subscription TestSubscription {
subject {
name
}
}
28 changes: 28 additions & 0 deletions tests/unit/mixins/component-query-manager-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,32 @@ module('Unit | Mixin | component query manager', function(hooks) {
);
done();
});

test('it unsubscribes from any subscriptions', function(assert) {
let done = assert.async();
let subject = this.subject();
let unsubscribeCalled = 0;

let apolloService = subject.get('apollo.apollo');
apolloService.set('managedSubscribe', (manager, opts) => {
assert.deepEqual(opts, { query: 'fakeSubscription' });
manager.trackSubscription({
unsubscribe() {
unsubscribeCalled++;
},
});
return {};
});

subject.get('apollo').subscribe({ query: 'fakeSubscription' });
subject.get('apollo').subscribe({ query: 'fakeSubscription' });

subject.willDestroyElement();
assert.equal(
unsubscribeCalled,
2,
'_apolloUnsubscribe() was called once per subscribe'
);
done();
});
});
Loading

0 comments on commit d113142

Please sign in to comment.