diff --git a/README.md b/README.md index 01c2b393..12776fef 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/addon/-private/mixins/base-query-manager.js b/addon/-private/mixins/base-query-manager.js index d3f14b54..ae0273cf 100644 --- a/addon/-private/mixins/base-query-manager.js +++ b/addon/-private/mixins/base-query-manager.js @@ -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(); + }), }); diff --git a/addon/apollo/query-manager.js b/addon/apollo/query-manager.js index bd98a46e..01693ec8 100644 --- a/addon/apollo/query-manager.js +++ b/addon/apollo/query-manager.js @@ -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. diff --git a/addon/services/apollo.js b/addon/services/apollo.js index afc31fe2..928b64f7 100644 --- a/addon/services/apollo.js +++ b/addon/services/apollo.js @@ -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); @@ -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. @@ -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 }); }, diff --git a/tests/unit/build/graphql-filter-test.js b/tests/unit/build/graphql-filter-test.js index 121ba196..0afa7950 100644 --- a/tests/unit/build/graphql-filter-test.js +++ b/tests/unit/build/graphql-filter-test.js @@ -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 }) { @@ -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` diff --git a/tests/unit/build/test-subscription.graphql b/tests/unit/build/test-subscription.graphql new file mode 100644 index 00000000..cf6948ca --- /dev/null +++ b/tests/unit/build/test-subscription.graphql @@ -0,0 +1,5 @@ +subscription TestSubscription { + subject { + name + } +} diff --git a/tests/unit/mixins/component-query-manager-test.js b/tests/unit/mixins/component-query-manager-test.js index dd665eab..465705a8 100644 --- a/tests/unit/mixins/component-query-manager-test.js +++ b/tests/unit/mixins/component-query-manager-test.js @@ -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(); + }); }); diff --git a/tests/unit/mixins/object-query-manager-test.js b/tests/unit/mixins/object-query-manager-test.js index 1b347601..e7c1574d 100644 --- a/tests/unit/mixins/object-query-manager-test.js +++ b/tests/unit/mixins/object-query-manager-test.js @@ -43,6 +43,34 @@ module('Unit | Mixin | ember object 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.willDestroy(); + assert.equal( + unsubscribeCalled, + 2, + '_apolloUnsubscribe() was called once per subscribe' + ); + done(); + }); + test('it exposes an apollo client object', function(assert) { let subject = this.subject(); let client = subject.get('apollo.apolloClient'); diff --git a/tests/unit/mixins/route-query-manager-test.js b/tests/unit/mixins/route-query-manager-test.js index 4e177b21..b74adbf2 100644 --- a/tests/unit/mixins/route-query-manager-test.js +++ b/tests/unit/mixins/route-query-manager-test.js @@ -43,6 +43,35 @@ module('Unit | Mixin | route 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.beforeModel(); + subject.resetController({}, true); + assert.equal( + unsubscribeCalled, + 2, + '_apolloUnsubscribe() was called once per subscribe' + ); + done(); + }); + test('it only unsubscribes from stale watchQuery subscriptions with isExiting=false', function(assert) { let done = assert.async(); let subject = this.subject(); diff --git a/tests/unit/services/apollo-test.js b/tests/unit/services/apollo-test.js index 7d387a14..7322217e 100644 --- a/tests/unit/services/apollo-test.js +++ b/tests/unit/services/apollo-test.js @@ -4,6 +4,7 @@ import { computed } from '@ember/object'; import ApolloService from 'ember-apollo-client/services/apollo'; import testQuery from '../build/test-query'; import testMutation from '../build/test-mutation'; +import testSubscription from '../build/test-subscription'; import { Promise } from 'rsvp'; module('Unit | Service | apollo', function(hooks) { @@ -113,4 +114,51 @@ module('Unit | Service | apollo', function(hooks) { const result = await service.watchQuery({ query: testQuery }, 'human'); assert.equal(result.get('name'), undefined); }); + + test('.subscribe with key', async function(assert) { + let service = this.owner.lookup('service:apollo'); + let nextFunction = null; + + service.set('client', { + subscribe() { + assert.ok(true, 'Called subscribe function on apollo client'); + + return { + subscribe({ next }) { + nextFunction = next; + }, + }; + }, + }); + + const result = await service.subscribe( + { + subscription: testSubscription, + }, + 'human' + ); + + const names = []; + result.on('event', e => names.push(e.name)); + + // Things initialize as empty + assert.equal(result.get('lastEvent'), null); + + // Two updates come in + nextFunction({ data: { human: { name: '1 Link' }, __typename: 'person' } }); + nextFunction({ data: { human: { name: '2 Luke' }, __typename: 'person' } }); + + // Events are in the correct order + assert.equal(result.get('lastEvent.name'), '2 Luke'); + // Event streams are in the correct order + assert.equal(names.join(' '), '1 Link 2 Luke'); + + nextFunction({ data: { human: { name: '3 Greg' }, __typename: 'person' } }); + // Stream null + nextFunction({ loading: true }); + nextFunction({ loading: true, data: null }); + // Still have last event + assert.equal(result.get('lastEvent.name'), '3 Greg'); + assert.equal(names.join(' '), '1 Link 2 Luke 3 Greg'); + }); });