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

Feature/subscription support #173

Merged
Merged
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
115 changes: 115 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,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);
}),
});

```
coladarci marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -236,7 +341,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