diff --git a/README.md b/README.md index 678da14..aacf92f 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ Features: -* Plug and Play Zero-Config GraphQL Server -* GraphiQL + Subscription Support -* Apollo Live Features (Reactive Scalable Queries) -* MongoDB Tailored -* Date and JSON scalars -* HTTP and Subscription built-in Authentication (+ GraphiQL Authentication Support) -* Meteor Accounts (Plug & Play) +- Plug and Play Zero-Config GraphQL Server +- GraphiQL + Subscription Support +- Apollo Live Features (Reactive Scalable Queries) +- MongoDB Tailored +- Date and JSON scalars +- HTTP and Subscription built-in Authentication (+ GraphiQL Authentication Support) +- Meteor Accounts (Plug & Play) ## Install @@ -19,7 +19,7 @@ meteor create --bare graphql-baby cd graphql-baby # Now we install our npm dependencies -meteor npm i -S graphql graphql-load subscriptions-transport-ws apollo-live-server apollo-live-client apollo-client apollo-cache-inmemory apollo-link apollo-link-http apollo-link-ws express apollo-server-express uuid graphql-subscriptions body-parser graphql-tools graphql-type-json +meteor npm i -S graphql graphql-load subscriptions-transport-ws apollo-live-server apollo-live-client apollo-client apollo-cache-inmemory apollo-link apollo-link-http apollo-link-ws express apollo-server-express uuid graphql-subscriptions body-parser graphql-tools graphql-type-json apollo-morpher # Now we add the package meteor add cultofcoders:apollo @@ -71,8 +71,8 @@ query { ### Useful packages -* [graphql-load](https://www.npmjs.com/package/graphql-load?activeTab=readme) -* [disable-introspection](https://github.com/helfer/graphql-disable-introspection) +- [graphql-load](https://www.npmjs.com/package/graphql-load?activeTab=readme) +- [disable-introspection](https://github.com/helfer/graphql-disable-introspection) ## Premium Support diff --git a/__tests__/client.js b/__tests__/client.js new file mode 100644 index 0000000..7921e3b --- /dev/null +++ b/__tests__/client.js @@ -0,0 +1 @@ +import './morpher/morpher.test'; diff --git a/__tests__/graphql/init.js b/__tests__/graphql/init.js new file mode 100644 index 0000000..033c91a --- /dev/null +++ b/__tests__/graphql/init.js @@ -0,0 +1,4 @@ +import { load } from 'meteor/cultofcoders:apollo'; +import { typeDefs } from './module'; + +load({ typeDefs }); diff --git a/__tests__/graphql/module.js b/__tests__/graphql/module.js new file mode 100644 index 0000000..06cd977 --- /dev/null +++ b/__tests__/graphql/module.js @@ -0,0 +1,26 @@ +const typeDefs = ` + type User @mongo(name:"users") { + _id: ID! + firstName: String + lastName: String + posts: [Post] @link(to: "author") + comments: [Comment] @link(to: "user") + } + + type Post @mongo(name:"posts") { + _id: ID! + name: String + author: User @link(field: "authorId") + authorId: String + comments: [Comment] @link(to: "post") + } + + type Comment @mongo(name:"comments") { + _id: ID! + text: String + user: User @link(field: "userId") + post: Post @link(field: "postId") + } +`; + +export { typeDefs }; diff --git a/__tests__/morpher/client.js b/__tests__/morpher/client.js new file mode 100644 index 0000000..b1c6e5e --- /dev/null +++ b/__tests__/morpher/client.js @@ -0,0 +1,8 @@ +import { initialize } from 'meteor/cultofcoders:apollo'; +import { setClient } from 'apollo-morpher'; + +const { client } = initialize(); + +setClient(client); + +export default client; diff --git a/__tests__/morpher/init.server.js b/__tests__/morpher/init.server.js new file mode 100644 index 0000000..0a8d729 --- /dev/null +++ b/__tests__/morpher/init.server.js @@ -0,0 +1,12 @@ +import { expose, db } from 'meteor/cultofcoders:apollo'; + +expose({ + users: { + type: 'User', + collection: () => db.users, + update: () => true, + insert: () => true, + remove: () => true, + find: () => true, + }, +}); diff --git a/__tests__/morpher/morpher.test.js b/__tests__/morpher/morpher.test.js new file mode 100644 index 0000000..976b9f6 --- /dev/null +++ b/__tests__/morpher/morpher.test.js @@ -0,0 +1,89 @@ +import db from 'apollo-morpher'; +import client from './client'; + +describe('Morpher', () => { + it('Should work with insert()', done => { + db.users + .insert({ + firstName: 'John', + lastName: 'Smith', + }) + .then(result => { + assert.isString(result._id); + done(); + }); + }); + it('Should work with update()', async () => { + const { _id } = await db.users.insert({ + firstName: 'John', + lastName: 'Smith', + }); + + const response = await db.users.update( + { _id }, + { + $set: { lastName: 'Brown' }, + } + ); + + assert.equal('ok', response); + }); + + it('Should work with remove()', async () => { + const { _id } = await db.users.insert({ + firstName: 'John', + lastName: 'Smith', + }); + + const response = await db.users.remove({ _id }); + + assert.equal('ok', response); + }); + + it('Should work with find()', async () => { + const { _id } = await db.users.insert({ + firstName: 'John', + lastName: 'Smith', + }); + + const users = await db.users.find( + { + _id: 1, + firstName: 1, + lastName: 1, + }, + { + filters: { _id }, + } + ); + + assert.lengthOf(users, 1); + assert.isObject(users[0]); + assert.equal(_id, users[0]._id); + assert.equal('John', users[0].firstName); + assert.equal('Smith', users[0].lastName); + }); + + it('Should work with findOne()', async () => { + const { _id } = await db.users.insert({ + firstName: 'John', + lastName: 'Smith', + }); + + const user = await db.users.findOne( + { + _id: 1, + firstName: 1, + lastName: 1, + }, + { + filters: { _id }, + } + ); + + assert.isObject(user); + assert.equal(_id, user._id); + assert.equal('John', user.firstName); + assert.equal('Smith', user.lastName); + }); +}); diff --git a/__tests__/server.js b/__tests__/server.js new file mode 100644 index 0000000..dc6bc8d --- /dev/null +++ b/__tests__/server.js @@ -0,0 +1,5 @@ +import { initialize } from 'meteor/cultofcoders:apollo'; +import './graphql/init'; +import './morpher/init.server'; + +initialize(); diff --git a/docs/accounts.md b/docs/accounts.md index b39cdb7..933de2b 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -149,4 +149,4 @@ load({ --- -### [Table of Contents](table-of-contents.md) +### [Table of Contents](index.md) diff --git a/docs/client.md b/docs/client.md index fada446..9a4b29a 100644 --- a/docs/client.md +++ b/docs/client.md @@ -62,8 +62,8 @@ initialize({ }) ``` -* [Read more about React Apollo](https://www.apollographql.com/docs/react/) +- [Read more about React Apollo](https://www.apollographql.com/docs/react/) --- -### [Table of Contents](table-of-contents.md) +### [Table of Contents](index.md) diff --git a/docs/db.md b/docs/db.md index 41ffa19..843ae88 100644 --- a/docs/db.md +++ b/docs/db.md @@ -39,12 +39,12 @@ type Group @mongo(name: "groups") { Above we have the following relationships: -* Post has one Author and it's stored in `authorId` -* Post has many Comments and is linked to the `post` link -* Comment is linked to a post, and that is stored in `postId` -* Author has many Posts and is linked to the `author` link -* Author belongs to many Groups and it's stored in `groupIds` -* Groups have many Authors and is linked to `groups` link +- Post has one Author and it's stored in `authorId` +- Post has many Comments and is linked to the `post` link +- Comment is linked to a post, and that is stored in `postId` +- Author has many Posts and is linked to the `author` link +- Author belongs to many Groups and it's stored in `groupIds` +- Groups have many Authors and is linked to `groups` link And the beautiful part is that for prototyping this is so fast, because we inject the db inside our context: @@ -94,14 +94,14 @@ export default { }; ``` -* [Read more about Grapher](https://github.com/cult-of-coders/grapher) -* [Read more about Grapher's performance](https://github.com/theodorDiaconu/grapher-performance) -* [Read more about Grapher Directives](https://github.com/cult-of-coders/grapher-schema-directives) -* [Read more about Grapher & GraphQL](https://github.com/cult-of-coders/grapher/blob/master/docs/graphql.md) +- [Read more about Grapher](https://github.com/cult-of-coders/grapher) +- [Read more about Grapher's performance](https://github.com/theodorDiaconu/grapher-performance) +- [Read more about Grapher Directives](https://github.com/cult-of-coders/grapher-schema-directives) +- [Read more about Grapher & GraphQL](https://github.com/cult-of-coders/grapher/blob/master/docs/graphql.md) Read more about advanced functionalities of Collections in Meteor: http://www.meteor-tuts.com/chapters/3/persistence-layer.html --- -### [Table of Contents](table-of-contents.md) +### [Table of Contents](index.md) diff --git a/docs/ddp.md b/docs/ddp.md index 815fb34..c8adf72 100644 --- a/docs/ddp.md +++ b/docs/ddp.md @@ -25,4 +25,4 @@ The logic here is that you can use `fusion`, and when your app wants to scale an --- -### [Table of Contents](table-of-contents.md) +### [Table of Contents](index.md) diff --git a/docs/grapher.md b/docs/grapher.md index a5b9e5e..d9e6941 100644 --- a/docs/grapher.md +++ b/docs/grapher.md @@ -2,9 +2,9 @@ You can use Grapher to define your links in your types, for rapid prototyping: -* [Read more about Grapher](https://github.com/cult-of-coders/grapher) -* [Read more about Grapher Directives](https://github.com/cult-of-coders/grapher-schema-directives) -* [Read more about Grapher & GraphQL](https://github.com/cult-of-coders/grapher/blob/master/docs/graphql.md) +- [Read more about Grapher](https://github.com/cult-of-coders/grapher) +- [Read more about Grapher Directives](https://github.com/cult-of-coders/grapher-schema-directives) +- [Read more about Grapher & GraphQL](https://github.com/cult-of-coders/grapher/blob/master/docs/graphql.md) ## Sample @@ -37,4 +37,4 @@ export default { --- -### [Table of Contents](table-of-contents.md) +### [Table of Contents](index.md) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..f83269e --- /dev/null +++ b/docs/index.md @@ -0,0 +1,17 @@ +# Table of Contents + +Hi there, if anythings are unclear, please help us improve this documentation by asking questions as issues, +and doing a PR to fix it. Thank you! + +- [Simple Usage](sample.md) +- [Work with Database](db.md) +- [Morph your database](morpher.md) +- [Accounts](accounts.md) +- [Scalars](scalars.md) +- [Live Queries](live_queries.md) +- [Client](client.md) +- [DDP](ddp.md) +- [Visualising](visualising.md) +- [Settings](settings.md) +- [React-Native Client Considerations](react-native-client.md) +- [Meteor Connectivty Simulation](meteor-connectivity.md) diff --git a/docs/live_queries.md b/docs/live_queries.md index 89ab816..51c0524 100644 --- a/docs/live_queries.md +++ b/docs/live_queries.md @@ -8,12 +8,13 @@ This package comes already with the [apollo-live-server](https://www.npmjs.com/p ```js type Subscription { - users: ReactiveEventUser + users: SubscriptionEvent } -type ReactiveEventUser { +// Subscription event is loaded automatically by this package and looks like this: +type SubscriptionEvent { event: String, - doc: User + doc: JSON } ``` @@ -36,6 +37,12 @@ export default { } ``` +Find out more: https://www.npmjs.com/package/apollo-live-server + +## Client usage: + +Please refer to the documentation here: https://github.com/cult-of-coders/apollo-live-client + ## Simulate reactivity ```js @@ -53,7 +60,7 @@ Meteor.setInterval(function() { }, 2000); ``` -You can now test your query inside GraphiQL: +You can now test your query inside GraphiQL, to see how easily it reacts to changes: ```js subscription { @@ -66,4 +73,4 @@ subscription { --- -### [Table of Contents](table-of-contents.md) +### [Table of Contents](index.md) diff --git a/docs/meteor-connectivity.md b/docs/meteor-connectivity.md index b98d33a..de8adce 100644 --- a/docs/meteor-connectivity.md +++ b/docs/meteor-connectivity.md @@ -2,17 +2,16 @@ For folks coming from the classic Meteor DDP environment, we are very used to reactivity on the connection to the server. For instance, when we connect to Meteor using DDP, we automatically have: -* Reactive connectivity status to server and event notification when that status changes. This helps us do things like navigating to splash-screen/login/post-login pages. -* Auto-login attempt when connection established +- Reactive connectivity status to server and event notification when that status changes. This helps us do things like navigating to splash-screen/login/post-login pages. +- Auto-login attempt when connection established You can still achieve that with this package, though there is a bit of extra setup. ## Meteor Connectivity Status Simulation -The standard Meteor/DDP behaviour is when the DDP over websocket connection is established, you get ```Meteor.status().connected``` and ```Meteor.user()```. When the link status changes, or the user logs in/out, you get reactive updates. To get such behaviour with this package, you can register handlers for onConnected, onReconnected, and onDisconnected events from the ```wsLink``` apollo-link that we created. +The standard Meteor/DDP behaviour is when the DDP over websocket connection is established, you get `Meteor.status().connected` and `Meteor.user()`. When the link status changes, or the user logs in/out, you get reactive updates. To get such behaviour with this package, you can register handlers for onConnected, onReconnected, and onDisconnected events from the `wsLink` apollo-link that we created. ```js - let linkBounce = false; // this is to handle intentional bouncing of our link // our websocket link for subscriptions @@ -20,44 +19,42 @@ const wsLink = new WebSocketLink({ uri: constants.GRAPHQL_SUBSCRIPTION_ENDPOINT, options: { reconnect: true, - connectionParams: () => ( // a promise that resolves to return the loginToken - new Promise((resolve, reject) => { // eslint-disable-line no-undef,no-unused-vars - getLoginToken().then(token => { - if (token) { - console.log("wsLink loginToken = " + token); - resolve({ - [constants.AUTH_TOKEN_KEY]: token - }); - } else { - resolve({ - [constants.AUTH_TOKEN_KEY]: "" - }); - } - }); - })) - } + connectionParams: () => + // a promise that resolves to return the loginToken + new Promise((resolve, reject) => { + // eslint-disable-line no-undef,no-unused-vars + getLoginToken().then(token => { + if (token) { + console.log('wsLink loginToken = ' + token); + resolve({ + [constants.AUTH_TOKEN_KEY]: token, + }); + } else { + resolve({ + [constants.AUTH_TOKEN_KEY]: '', + }); + } + }); + }), + }, }); function _connected() { - console.log("WE ARE CONNECTED!"); - + console.log('WE ARE CONNECTED!'); + // do something here, say check user login status or navigate to appropriate page - } function _disconnected() { - console.log("WE ARE DISCONNECTED!"); + console.log('WE ARE DISCONNECTED!'); // if we are intentionally bouncing our link, don't do anything if (!linkBounce) { - - // do something here, like flush all subscriptions or navigate to a different page - + // do something here, like flush all subscriptions or navigate to a different page } // reset link bounce flag linkBounce = false; - } function setupServerConnectionListeners() { @@ -67,14 +64,13 @@ function setupServerConnectionListeners() { wsLink.subscriptionClient.onDisconnected(_disconnected); // bounce websocket link in subscription client to reconnect so that our listeners are active - console.log("bouncing websocket link"); + console.log('bouncing websocket link'); linkBounce = true; wsLink.subscriptionClient.close(false, false); - } ``` -Notice that in our ```setupServerConnectionListeners()``` function, we bounce the link after we set up the listeners. This is because the websocket connection is established when you create the wsLink and the listeners will not be active yet. We bounce the link to activate the listeners. Also notice that we don'd do anything in our ```_disconnect()``` handler when we are intentionally bouncing the link. +Notice that in our `setupServerConnectionListeners()` function, we bounce the link after we set up the listeners. This is because the websocket connection is established when you create the wsLink and the listeners will not be active yet. We bounce the link to activate the listeners. Also notice that we don'd do anything in our `_disconnect()` handler when we are intentionally bouncing the link. You should be able to test this now by bringing either the server or client up and down, and you should observer Meteor like connection reactivity. @@ -82,7 +78,7 @@ Keep in mind that the subscriptions-transport-ws uses a backoff system to reconn ## Auto-login of User -Meteor users are also used to have the system auto-login the user if there is a valid login token, and having a ```Meteor.user()```. We can get this type of behaviour by setting up a query to retrieve user information. Let's call it a ```whoAmI``` query. Obviously, we need to set this schema and resolver on the server: +Meteor users are also used to have the system auto-login the user if there is a valid login token, and having a `Meteor.user()`. We can get this type of behaviour by setting up a query to retrieve user information. Let's call it a `whoAmI` query. Obviously, we need to set this schema and resolver on the server: ### Server @@ -91,14 +87,12 @@ Let's set up the schema first: ```graphql # file user.graphql -type Email -{ - address: String - verified: Boolean +type Email { + address: String + verified: Boolean } -type User @mongo(name: "users") -{ +type User @mongo(name: "users") { _id: ID! emails: [Email] firstName: String @@ -117,88 +111,96 @@ Next, our resolver: ```js // file user.resolver.js -import { Meteor } from "meteor/meteor"; +import { Meteor } from 'meteor/meteor'; export default { User: { fullName(user) { - return (`${user.profile.firstName} ${user.profile.lastName}`); + return `${user.profile.firstName} ${user.profile.lastName}`; }, firstName(user) { - return (`${user.profile.firstName}`); + return `${user.profile.firstName}`; }, lastName(user) { - return (`${user.profile.lastName}`); - } + return `${user.profile.lastName}`; + }, }, Query: { - users(_, args, {db}, ast) { + users(_, args, { db }, ast) { // use grapher to do the query instead of direct mongo query - let allUsers = db.users.astToQuery(ast, { - embody({body, getArgs}) { // eslint-disable-line no-unused-vars - // grab profile too if not already included - if (!body.profile) { - Object.assign(body, { - profile: 1 - }); - } - } - }).fetch(); + let allUsers = db.users + .astToQuery(ast, { + embody({ body, getArgs }) { + // eslint-disable-line no-unused-vars + // grab profile too if not already included + if (!body.profile) { + Object.assign(body, { + profile: 1, + }); + } + }, + }) + .fetch(); return allUsers; }, // this is our 'me' query resolver me(_, args, context) { - return Meteor.users.findOne(context.userId); // notice we look for the userId, so we are looking for a valid login token - } - } + return Meteor.users.findOne(context.userId); // notice we look for the userId, so we are looking for a valid login token + }, + }, }; ``` ### Client -Now that we have set up our server, we can query for the user once the websocket connection has been established. So we change our ```_connected()``` connection handler to: +Now that we have set up our server, we can query for the user once the websocket connection has been established. So we change our `_connected()` connection handler to: ```js -import gql from "graphql-tag"; +import gql from 'graphql-tag'; // checks user info function whoAmI() { const myInfo = gql` - query { - me { - _id - firstName - lastName - fullName - } - }`; - - return new Promise((resolve, reject) => { // eslint-disable-line no-undef + query { + me { + _id + firstName + lastName + fullName + } + } + `; + + return new Promise((resolve, reject) => { + // eslint-disable-line no-undef // execute the query - client.query({ - query: myInfo, - fetchPolicy: "no-cache" // we don't want to cache the results - }).then((result) => { - console.log("user query success: " + JSON.stringify(result)); - resolve(result.data); - }).catch((error) => { - console.log("user query error: " + error); - reject(null); - }); + client + .query({ + query: myInfo, + fetchPolicy: 'no-cache', // we don't want to cache the results + }) + .then(result => { + console.log('user query success: ' + JSON.stringify(result)); + resolve(result.data); + }) + .catch(error => { + console.log('user query error: ' + error); + reject(null); + }); }); } function _connected() { - console.log("WE ARE CONNECTED!"); + console.log('WE ARE CONNECTED!'); // check our login status - whoAmI().then((data) => { + whoAmI().then(data => { if (data && data.me) { - console.log("we are already logged in!"); + console.log('we are already logged in!'); // we have a valid login token, do something, maybe go to post-login page // we also have a valid userId in data.me._id, so we could use that elsewhere } else if (data && !data.me) { - console.log("we are not logged in!"); + console.log('we are not logged in!'); // we do not have a valid login token, proceed to login page } }); @@ -207,8 +209,10 @@ function _connected() { With this, once, the websocket connection has been established, it will do a http-link query to get the user information. This assumes the httpLink has been set up correctly. -Keep in mind that the user infor you get back is not truly reactive like ```Meteor.user()```. This just tells you whether you have a valid login token or not. If you want to have your user information to be reactive, you need to set up a users subscription. +Keep in mind that the user infor you get back is not truly reactive like `Meteor.user()`. This just tells you whether you have a valid login token or not. If you want to have your user information to be reactive, you need to set up a users subscription. With this, you have achieved some level of the Meteor connectivity reactiviy we have all gotten used to. Hopefully, this was useful! +--- +### [Table of Contents](index.md) diff --git a/docs/morpher.md b/docs/morpher.md new file mode 100644 index 0000000..e796dfe --- /dev/null +++ b/docs/morpher.md @@ -0,0 +1,88 @@ +# Morpher + +This comes built in with cultofcoders:apollo package! + +## Install + +``` +meteor npm i -S apollo-morpher +``` + +### Server Usage + +```js +// api/server +import { expose } from 'meteor/cultofcoders:apollo'; + +expose({ + users: { + type: 'User' + collection: () => collection, + update: (ctx, {selector, modifier, modifiedFields, modifiedTopLevelFields}) => true, + insert: (ctx, {document}) => true, + remove: (ctx, {selector}) => true, + find(ctx, params) { + // params is an object + // by default filters, options are always empty objects, if they were not passed + // if you pass other params filters and options will still be empty objects + + // You have two options here: + // 1. Modify params.filters and params.options and don't return anything + params.filters.userId = ctx.userId + + // 2. Modify filters options based on other parameters sent out + if (params.accepted) { + params.filters.accepted = true; + } + + // 3. Return astToQueryOptions from Grapher for custom query support + // https://github.com/cult-of-coders/grapher/blob/master/docs/graphql.md + return { + $filters: params.filters, + $options: params.options + } + } +}) +``` + +### Client Usage + +```js +// Then on the client +import db, { setClient } from 'apollo-morpher'; + +// Set your Apollo client +setClient(apolloClient); + +// Built-in mutations +db.users.insert(document).then(({ _id }) => {}); +db.users.update(selector, modifier).then(response => {}); +db.users.remove(selector).then(response => {}); + +// Or define the in object style: +const fields = { + firstName: 1, + lastName: 1, + lastInvoices: { + total: 1, + }, +}; + +// Or you could also define the fields in GraphQL style `firstName` + +db.users + .find(fields, { + filters: {}, + options: {}, + }) + .then(result => {}); + +// find equivallent .findOne() +db.users.findOne(fields, { + filters: { _id: 'XXX' }, +}); +``` + +--- + +### [Table of Contents](index.md) diff --git a/docs/react-native-client.md b/docs/react-native-client.md index 8950ed3..490079b 100644 --- a/docs/react-native-client.md +++ b/docs/react-native-client.md @@ -2,27 +2,27 @@ Things mostly should just work out of the box for a react-native client. Make sure you install the following packages: -* apollo-cache-redux -* apollo-client -* apollo-link -* apollo-link-context -* apollo-link-error -* apollo-link-http -* apollo-link-ws -* apollo-live-client -* apollo-utilities -* core-js -* meteor-apollo-accounts -* react-apollo -* subscriptions-transport-ws +- apollo-cache-redux +- apollo-client +- apollo-link +- apollo-link-context +- apollo-link-error +- apollo-link-http +- apollo-link-ws +- apollo-live-client +- apollo-utilities +- core-js +- meteor-apollo-accounts +- react-apollo +- subscriptions-transport-ws If you don't use redux, install apollo-cache-inmemory instead of apollo-cache-redux, or any other cache implementation. There are, however, a few things to keep in mind: -* Authentication token is usually stored in AsyncStorage, and retrieval of the token is an asynchronous process. -* The authorization header needs to set up appropriately for both http links and websocket links. -* Some polyfills are needed +- Authentication token is usually stored in AsyncStorage, and retrieval of the token is an asynchronous process. +- The authorization header needs to set up appropriately for both http links and websocket links. +- Some polyfills are needed Since retrieval of the token is an asynchronous process, care must be taken to set it up appropriately for your http and websocket links. Let us first create a function to retrieve the token from AsyncStorage as a promise: @@ -32,23 +32,24 @@ let loginToken; // gets token from cache or from AsyncStorage function getLoginToken() { - - return new Promise((resolve, reject) => { // eslint-disable-line no-undef + return new Promise((resolve, reject) => { + // eslint-disable-line no-undef if (loginToken) { - console.log("resolving login token " + loginToken); + console.log('resolving login token ' + loginToken); resolve(loginToken); } else { - AsyncStorage.getItem(constants.AUTH_TOKEN_LOCALSTORAGE).then((token) => { - console.log("retrieved login token from AsyncStorage: " + token); - loginToken = token; - resolve(token); - }).catch(() => { - console.log("no login token found!"); - reject(""); - }); + AsyncStorage.getItem(constants.AUTH_TOKEN_LOCALSTORAGE) + .then(token => { + console.log('retrieved login token from AsyncStorage: ' + token); + loginToken = token; + resolve(token); + }) + .catch(() => { + console.log('no login token found!'); + reject(''); + }); } }); - } ``` @@ -56,25 +57,27 @@ Notice that we use a cache to avoid unnecessary lookups into AsyncStorage. We no ## HTTP Link -We will use ```setContext()``` from [apollo-link-context](https://github.com/apollographql/apollo-link/tree/master/packages/apollo-link-context) to set the authorization header: +We will use `setContext()` from [apollo-link-context](https://github.com/apollographql/apollo-link/tree/master/packages/apollo-link-context) to set the authorization header: ```js // our http link const httpLink = createHttpLink({ - uri: constants.GRAPHQL_ENDPOINT + uri: constants.GRAPHQL_ENDPOINT, }); -const AUTH_TOKEN_KEY = "meteor-login-token"; +const AUTH_TOKEN_KEY = 'meteor-login-token'; // create a link to insert the authorization header for http(s) -const authorizationLink = setContext(operation => getLoginToken().then(token => { // eslint-disable-line no-unused-vars - return { - // set meteor token here - headers: { - [AUTH_TOKEN_KEY]: token || null - } - }; -}) +const authorizationLink = setContext(operation => + getLoginToken().then(token => { + // eslint-disable-line no-unused-vars + return { + // set meteor token here + headers: { + [AUTH_TOKEN_KEY]: token || null, + }, + }; + }) ); // create our query/mutation link which uses http(s) @@ -87,8 +90,8 @@ Now, every time the http link is used, it will query the latest login token. Thi Things are slightly more complicated for websocket links which are required for subscriptions: -* First, [apollo-link-ws](https://github.com/apollographql/apollo-link/tree/master/packages/apollo-link-ws) uses [subscriptions-transport-ws](https://github.com/apollographql/subscriptions-transport-ws), the latest version of which (0.9.9 as of this writing) does not support an asynchronous call to set connection parameters headers with the authorization token. -* Second, the token is sent only once when the connection is established, so if/when the token changes, the connection needs to be reestablished. +- First, [apollo-link-ws](https://github.com/apollographql/apollo-link/tree/master/packages/apollo-link-ws) uses [subscriptions-transport-ws](https://github.com/apollographql/subscriptions-transport-ws), the latest version of which (0.9.9 as of this writing) does not support an asynchronous call to set connection parameters headers with the authorization token. +- Second, the token is sent only once when the connection is established, so if/when the token changes, the connection needs to be reestablished. For the first issue, we need to patch [subscriptions-transport-ws](https://github.com/apollographql/subscriptions-transport-ws). Here is the patch: @@ -123,22 +126,24 @@ const wsLink = new WebSocketLink({ uri: constants.GRAPHQL_SUBSCRIPTION_ENDPOINT, options: { reconnect: true, - connectionParams: () => ( // a promise that resolves to return the loginToken - new Promise((resolve, reject) => { // eslint-disable-line no-undef,no-unused-vars - getLoginToken().then(token => { - if (token) { - console.log("wsLink loginToken = " + token); - resolve({ - [constants.AUTH_TOKEN_KEY]: token - }); - } else { - resolve({ - [constants.AUTH_TOKEN_KEY]: "" - }); - } - }); - })) - } + connectionParams: () => + // a promise that resolves to return the loginToken + new Promise((resolve, reject) => { + // eslint-disable-line no-undef,no-unused-vars + getLoginToken().then(token => { + if (token) { + console.log('wsLink loginToken = ' + token); + resolve({ + [constants.AUTH_TOKEN_KEY]: token, + }); + } else { + resolve({ + [constants.AUTH_TOKEN_KEY]: '', + }); + } + }); + }), + }, }); ``` @@ -150,32 +155,40 @@ However, we still need address the second issue where the authorization token is wsLink.subscriptionClient.close(false, false); ``` -This needs to be done at an appropraiate place, such when your client receives a new login token. If you are using [meteor-apollo-accounts](https://github.com/cult-of-coders/meteor-apollo-accounts), then the loginToken change will happen in the ```setTokenStore()``` implementation: +This needs to be done at an appropraiate place, such when your client receives a new login token. If you are using [meteor-apollo-accounts](https://github.com/cult-of-coders/meteor-apollo-accounts), then the loginToken change will happen in the `setTokenStore()` implementation: ```js -import { loginWithPassword, onTokenChange, setTokenStore } from "meteor-apollo-accounts"; -import { getMainDefinition } from "apollo-utilities"; - -const AUTH_TOKEN_KEY = "meteor-login-token"; -const AUTH_TOKEN_LOCALSTORAGE = "Meteor.loginToken"; -const AUTH_TOKEN_EXPIRY = "Meteor.loginTokenExpires"; -const AUTH_USER_ID = "Meteor.userId"; - -const link = split(({query}) => { - const {kind, operation} = getMainDefinition(query); - return kind === "OperationDefinition" && operation === "subscription"; -}, wsLink, queryLink); +import { + loginWithPassword, + onTokenChange, + setTokenStore, +} from 'meteor-apollo-accounts'; +import { getMainDefinition } from 'apollo-utilities'; + +const AUTH_TOKEN_KEY = 'meteor-login-token'; +const AUTH_TOKEN_LOCALSTORAGE = 'Meteor.loginToken'; +const AUTH_TOKEN_EXPIRY = 'Meteor.loginTokenExpires'; +const AUTH_USER_ID = 'Meteor.userId'; + +const link = split( + ({ query }) => { + const { kind, operation } = getMainDefinition(query); + return kind === 'OperationDefinition' && operation === 'subscription'; + }, + wsLink, + queryLink +); // our apollo client const client = new ApolloClient({ link, - cache + cache, }); // override setTokenStore to store login token in AsyncStorage setTokenStore({ - set: async function ({userId, token, tokenExpires}) { - console.log("setting new token " + token); + set: async function({ userId, token, tokenExpires }) { + console.log('setting new token ' + token); loginToken = token; // update cache await AsyncStorage.setItem(AUTH_USER_ID, userId); await AsyncStorage.setItem(AUTH_TOKEN_LOCALSTORAGE, token); @@ -186,22 +199,22 @@ setTokenStore({ client.resetStore(); // bounce the websocket link so that new token gets sent linkBounce = true; - console.log("bouncing websocket link"); + console.log('bouncing websocket link'); wsLink.subscriptionClient.close(false, false); } }, - get: async function () { + get: async function() { return { userId: await AsyncStorage.getItem(AUTH_USER_ID), token: await AsyncStorage.getItem(AUTH_TOKEN_LOCALSTORAGE), - tokenExpires: await AsyncStorage.getItem(AUTH_TOKEN_EXPIRY) + tokenExpires: await AsyncStorage.getItem(AUTH_TOKEN_EXPIRY), }; - } + }, }); // callback when token changes onTokenChange(function() { - console.log("token did change"); + console.log('token did change'); client.resetStore(); // client is the apollo client instance }); ``` @@ -210,7 +223,7 @@ One thing to note is that, don't start a new subscription immediately after boun ```js function _connected() { - console.log("WE ARE CONNECTED!"); + console.log('WE ARE CONNECTED!'); // do someting here... } @@ -227,9 +240,9 @@ On react-native, there are some other quirky issues. The meteor-apollo-accounts // https://github.com/orionsoft/meteor-apollo-accounts/issues/73 // solution was suggested here... // https://github.com/facebook/react-native/issues/4676#issuecomment-163399041 -import "core-js/es6/symbol"; -import "core-js/fn/symbol/iterator"; -import "core-js/es6/set"; +import 'core-js/es6/symbol'; +import 'core-js/fn/symbol/iterator'; +import 'core-js/es6/set'; ``` Another polyfill is needed to handle Object.setProtoypeOf() which is used by apollo-client and not supported by android: @@ -238,12 +251,18 @@ Another polyfill is needed to handle Object.setProtoypeOf() which is used by apo // This polyfill is to address an issue on android // Object.setProtoypeOf is not defined on android // https://github.com/apollographql/apollo-client/issues/3236 -Object.setPrototypeOf = Object.setPrototypeOf || function(obj, proto) { - obj.__proto__ = proto; // eslint-disable-line no-proto - return obj; -}; +Object.setPrototypeOf = + Object.setPrototypeOf || + function(obj, proto) { + obj.__proto__ = proto; // eslint-disable-line no-proto + return obj; + }; ``` Do both of these at the top of your main application file. This should get you functional on react-native! + +--- + +### [Table of Contents](index.md) diff --git a/docs/sample.md b/docs/sample.md index ad4928a..1e7dd0a 100644 --- a/docs/sample.md +++ b/docs/sample.md @@ -68,4 +68,4 @@ Keep in mind, you can separate queries anyway you want, and `load()` them indepe --- -### [Table of Contents](table-of-contents.md) +### [Table of Contents](index.md) diff --git a/docs/scalars.md b/docs/scalars.md index 9d45cc8..56629ce 100644 --- a/docs/scalars.md +++ b/docs/scalars.md @@ -3,7 +3,7 @@ Read more about scalars: https://www.apollographql.com/docs/graphql-tools/scalars.html -This package comes with 2 scalars `Date` (because it's very common) and `JSON` (because we need it for easy reactivity inside [`apollo-live-server`](https://github.com/cult-of-coders/apollo-live-server). +This package comes with 2 scalars `Date` (because it's very common) and `JSON` (because we need it for easy reactivity inside [`apollo-live-server`](https://github.com/cult-of-coders/apollo-live-server) and for easily using it with [Morpher](./morpher.md) You can use it in your types: @@ -18,4 +18,4 @@ The `Date` scalar parses `.toISOString()`, so, when you want to send a date from --- -### [Table of Contents](table-of-contents.md) +### [Table of Contents](index.md) diff --git a/docs/settings.md b/docs/settings.md index 2195a94..833525d 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -57,4 +57,4 @@ When initializing, we accept as an argument a configuration object: --- -### [Table of Contents](table-of-contents.md) +### [Table of Contents](index.md) diff --git a/docs/table-of-contents.md b/docs/table-of-contents.md deleted file mode 100644 index 324e7d0..0000000 --- a/docs/table-of-contents.md +++ /dev/null @@ -1,16 +0,0 @@ -# Table of Contents - -Hi there, if anythings are unclear, please help us improve this documentation by asking questions as issues, -and doing a PR to fix it. Thank you! - -* [1. Simple Usage](sample.md) -* [2. Work with Database](db.md) -* [3. Accounts](accounts.md) -* [4. Scalars](scalars.md) -* [5. Live Queries](live_queries.md) -* [6. Client](client.md) -* [7. DDP](ddp.md) -* [8. Visualising](visualising.md) -* [9. Settings](settings.md) -* [10. React-Native Client Considerations](react-native-client.md) -* [11. Meteor Connectivty Simulation](meteor-connectivity.md) diff --git a/docs/visualising.md b/docs/visualising.md index f6249f4..5449cd8 100644 --- a/docs/visualising.md +++ b/docs/visualising.md @@ -69,4 +69,4 @@ Now open the html file directly in your browser, and engage in the nice schema v --- -### [Table of Contents](table-of-contents.md) +### [Table of Contents](index.md) diff --git a/package.js b/package.js index e9f6c9b..76ca5e9 100644 --- a/package.js +++ b/package.js @@ -1,6 +1,6 @@ Package.describe({ name: 'cultofcoders:apollo', - version: '0.3.1', + version: '0.4.0', // Brief, one-line summary of the package. summary: 'Meteor & Apollo integration', // URL to the Git repository containing the source code for this package. @@ -15,7 +15,7 @@ Package.onUse(function(api) { api.use('ecmascript'); api.use('check'); api.use('mongo'); - api.use('cultofcoders:grapher@1.3.4'); + api.use('cultofcoders:grapher@1.3.6'); api.use('cultofcoders:grapher-schema-directives@0.1.4'); api.use('accounts-base', { weak: true }); @@ -23,4 +23,18 @@ Package.onUse(function(api) { api.mainModule('server/index.js', 'server'); }); -Package.onTest(function(api) {}); +Package.onTest(function(api) { + api.use('cultofcoders:apollo'); + + var packages = [ + 'ecmascript', + 'cultofcoders:mocha', + 'practicalmeteor:chai', + 'mongo', + ]; + + api.use(packages); + + api.mainModule('__tests__/server.js', 'server'); + api.mainModule('__tests__/client.js', 'client'); +}); diff --git a/server/index.js b/server/index.js index 50313d1..322e00e 100644 --- a/server/index.js +++ b/server/index.js @@ -1,5 +1,11 @@ +import { load } from 'graphql-load'; +import { db } from 'meteor/cultofcoders:grapher'; + import './scalars'; import './types'; export { default as Config } from './config'; export { getUserForContext } from './core/users'; export { default as initialize } from './initialize'; +export { default as expose } from './morpher/expose'; + +export { load, db }; diff --git a/server/morpher/expose.js b/server/morpher/expose.js new file mode 100644 index 0000000..e2f859a --- /dev/null +++ b/server/morpher/expose.js @@ -0,0 +1,76 @@ +import { check, Match } from 'meteor/check'; +import { db } from 'meteor/cultofcoders:grapher'; +import { load } from 'meteor/cultofcoders:apollo'; +import setupDataFetching from './setupDataFetching'; +import setupMutations from './setupMutations'; + +const MaybeBoolOrFunction = Match.Maybe(Match.OneOf(Boolean, Function)); + +const getConfig = object => { + check(object, { + type: String, + collection: Function, + update: MaybeBoolOrFunction, + insert: MaybeBoolOrFunction, + remove: MaybeBoolOrFunction, + find: MaybeBoolOrFunction, + }); + + const newObject = Object.assign( + { + subscription: true, + }, + object + ); + + return newObject; +}; + +const morph = config => { + for (name in config) { + let singleConfig = getConfig(config[name]); + let modules = exposeSingle(name, singleConfig); + + load(modules); + } +}; + +function exposeSingle(name, config) { + const { collection, type } = config; + + let modules = []; + + let { MutationType, Mutation } = setupMutations( + config, + name, + type, + collection + ); + + MutationType = `type Mutation { ${MutationType} }`; + + modules.push({ + typeDefs: MutationType, + resolvers: { Mutation }, + }); + + if (config.find) { + let { QueryType, Query } = setupDataFetching( + config, + name, + type, + collection + ); + + QueryType = `type Query { ${QueryType} }`; + + modules.push({ + typeDefs: [QueryType], + resolvers: { Query }, + }); + } + + return modules; +} + +export default morph; diff --git a/server/morpher/getFields.js b/server/morpher/getFields.js new file mode 100644 index 0000000..e0b64a7 --- /dev/null +++ b/server/morpher/getFields.js @@ -0,0 +1,44 @@ +import { _ } from 'meteor/underscore'; + +/** + * @param mutator + */ +export default function getFields(mutator) { + // compute modified fields + var fields = []; + var topLevelFields = []; + + _.each(mutator, function(params, op) { + if (op[0] == '$') { + _.each(_.keys(params), function(field) { + // record the field we are trying to change + if (!_.contains(fields, field)) { + // fields.push(field); + // topLevelFields.push(field.split('.')[0]); + + // like { $set: { 'array.1.xx' } } + const specificPositionFieldMatch = /\.[\d]+(\.)?/.exec(field); + if (specificPositionFieldMatch) { + fields.push(field.slice(0, specificPositionFieldMatch.index)); + } else { + if (field.indexOf('.$') !== -1) { + if (field.indexOf('.$.') !== -1) { + fields.push(field.split('.$.')[0]); + } else { + fields.push(field.split('.$')[0]); + } + } else { + fields.push(field); + } + } + + topLevelFields.push(field.split('.')[0]); + } + }); + } else { + fields.push(op); + } + }); + + return { fields, topLevelFields }; +} diff --git a/server/morpher/setupDataFetching.js b/server/morpher/setupDataFetching.js new file mode 100644 index 0000000..6f82b3c --- /dev/null +++ b/server/morpher/setupDataFetching.js @@ -0,0 +1,80 @@ +import { asyncIterator, astToFields, Event } from 'apollo-live-server'; + +export default function setupDataFetching(config, name, type, collection) { + let Query = {}; + let QueryType = ``; + let Subscription = {}; + let SubscriptionType = ``; + + QueryType += `${name}(params: JSON!): [${type}]`; + QueryType += `${name}Single(params: JSON!): ${type}`; + + // We are creating the function here because we are re-using it for Single ones + const fn = (_, { params }, ctx, ast) => { + let astToQueryOptions; + + if (typeof config.find === 'function') { + params = Object.assign( + { + filters: {}, + options: {}, + }, + params + ); + + let astToQueryOptions = config.find.call(null, ctx, params, ctx, ast); + if (astToQueryOptions === false) { + throw 'Unauthorised'; + } + } + + if (astToQueryOptions === undefined || astToQueryOptions === true) { + astToQueryOptions = { + $filters: params.filters || {}, + $options: params.options || {}, + }; + } + + return collection() + .astToQuery(ast, astToQueryOptions) + .fetch(); + }; + + Query = { + [name]: fn, + [name + 'Single'](_, args, ctx, ast) { + const result = fn.call(null, _, args, ctx, ast); + return result[0] || null; + }, + }; + + /** + * This will not be in the current release + * + if (config.subscription) { + SubscriptionType = `${name}(params: JSON!): SubscriptionEvent`; + Subscription = { + [name]: { + resolve: payload => { + if (config.subscriptionResolver) { + return config.subscriptionResolver.call(null, payload); + } + return payload; + }, + subscribe(_, { params }, ctx, ast) { + const fields = astToFields(ast)[doc]; + + if (typeof config.subscription === 'function') { + config.subscription.call(null, ctx, fields); + } + + const observable = collection().find({}, { fields }); + return asyncIterator(observable); + }, + }, + }; + } + */ + + return { QueryType, SubscriptionType, Query, Subscription }; +} diff --git a/server/morpher/setupMutations.js b/server/morpher/setupMutations.js new file mode 100644 index 0000000..7bb21fe --- /dev/null +++ b/server/morpher/setupMutations.js @@ -0,0 +1,58 @@ +import getFields from './getFields'; + +export default function setupMutations(config, name, type, collection) { + let Mutation = {}; + let MutationType = ``; + + if (config.insert) { + MutationType += `${name}Insert(document: JSON): ${type}\n`; + + Mutation[`${name}Insert`] = (_, { document }, ctx) => { + if (typeof config.insert === 'function') { + config.insert.call(null, ctx, { document }); + } + + const docId = collection().insert(document); + + return { + _id: docId, + }; + }; + } + + if (config.update) { + MutationType += `${name}Update(selector: JSON, modifier: JSON): String!\n`; + + Mutation[`${name}Update`] = (_, { selector, modifier }, ctx) => { + if (typeof config.update === 'function') { + const { topLevelFields, fields } = getFields(modifier); + config.update.call(null, ctx, { + selector, + modifier, + modifiedFields: fields, + modifiedTopLevelFields: topLevelFields, + }); + } + + const docId = collection().update(selector, modifier); + + return 'ok'; + }; + } + + if (config.remove) { + MutationType += `${name}Remove(selector: JSON): String\n`; + + Mutation[`${name}Remove`] = (_, { selector }, ctx) => { + if (typeof config.insert === 'function') { + config.remove.call(null, ctx, { selector }); + } + + collection().remove(selector); + + return 'ok'; + }; + } + + return { MutationType, Mutation }; +} diff --git a/server/types/SubscriptionEventType.js b/server/types/SubscriptionEventType.js new file mode 100644 index 0000000..37138bd --- /dev/null +++ b/server/types/SubscriptionEventType.js @@ -0,0 +1,6 @@ +export default ` + type SubscriptionEvent { + event: String + doc: JSON + } +`; diff --git a/server/types/index.js b/server/types/index.js index ee79ff2..1bba16e 100644 --- a/server/types/index.js +++ b/server/types/index.js @@ -1,6 +1,7 @@ import { load } from 'graphql-load'; import { typeDefs as directiveTypeDefs } from '../directives'; +import SubscriptionEventType from './SubscriptionEventType'; load({ - typeDefs: [...directiveTypeDefs], + typeDefs: [...directiveTypeDefs, SubscriptionEventType], });