diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..10d4330 --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +MOCHA_TARGET=test/specs.js + +test: + make testonly && make lint + +testonly: + mocha $(MOCHA_TARGET) + +testonly-watch: + mocha -w $(MOCHA_TARGET) + +lint: + standard . + +.PHONY: test testonly testonly-watch lint \ No newline at end of file diff --git a/README.md b/README.md index f92d8a7..f46c065 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,25 @@ npm install graphql-client -S ## How To ```javascript -var client = require('graphql-client')({url: 'http://your-host/graphql'}) +var client = require('graphql-client')({ + url: 'http://your-host/graphql' +}) + // Before request hook + .on('request', (req) => { + // Do whatever you want with `Request` instance, e.g. add JWT auth header + if (authenticated) { + req.headers.set('Authentication', 'Bearer ' + token) + } + }) + // On response hook. Access `Response` instance before parsing it's body + .on('response', (res) => { + ... + }) + // After response is parsed as JSON + .on('data', (data) => { + console.log('GraphQL response:', data) + }) + var query = ` query search ($query: String, $from: Int, $limit: Int) { diff --git a/index.js b/index.js index 38b2cb7..e8f582b 100644 --- a/index.js +++ b/index.js @@ -1,61 +1,129 @@ -function highlightQuery (query, errors) { - var locations = errors.map(function (e) { return e.locations }) - .reduce(function (a, b) { - return a.concat(b) - }, []) +/* global fetch, Headers */ +require('isomorphic-fetch') - var queryHighlight = '' +function Client (options) { + var self = this - query.split('\n').forEach(function (row, index) { - var line = index + 1 - var lineErrors = locations.filter(function (loc) { return loc.line === line }) + if (!options.url) throw new Error('Missing url parameter') - queryHighlight += row + '\n' + self.options = options + self.url = options.url - if (lineErrors.length) { - var errorHighlight = [] + // A stack of registered listeners + self.listeners = [] +} - lineErrors.forEach(function (line) { - for (var i = 0; i < 8; i++) { - errorHighlight[line.column + i] = '~' - } - }) +// to reduce file size +var proto = Client.prototype - for (var i = 0; i < errorHighlight.length; i++) { - queryHighlight += errorHighlight[i] || ' ' - } - queryHighlight += '\n' +/** + * Send a query and get a Promise + * @param {String} query + * @param {Object} variables + * @param {Function} beforeRequest hook + * @returns {Promise} + */ +proto.query = function (query, variables, beforeRequest) { + var self = this + + var req = self.options.request || {} + req.method || (req.method = 'POST') + if (!req.headers) { + req.headers = new Headers() + req.headers.set('content-type', 'application/json') + } + req.body = JSON.stringify({ + query: query, + variables: variables + }) + + // 'beforeRequest' is a top priority per-query hook, it should forcibly + // override response even from other hooks. + var result = beforeRequest && beforeRequest(req) + + if (typeof result === 'undefined') { + result = self.emit('request', req) + + // No 'response' hook here, reserve it for real responses only. + + // 'data' hook is only triggered if there are any data + if (typeof result !== 'undefined') { + var data = self.emit('data', result, true) // `true` for fake data + if (typeof data !== 'undefined') result = data } + } + + if (typeof result !== 'undefined') { + result = Promise.resolve(result) + } + return result || self.fetch(req) +} + +/** + * For making requests + * @param {Object} req + * @returns Promise + */ +proto.fetch = function (req) { + var self = this + + return fetch(self.url, req).then(function (res) { + // 'response' hook can redefine `res` + var _res = self.emit('response', res) + if (typeof _res !== 'undefined') res = _res + + return res.json() + }).then(function (data) { + // 'data' hook can redefine `data` + var _data = self.emit('data', data) + if (typeof _data !== 'undefined') data = _data + + return data }) +} + +/** + * Register a listener. + * @param {String} eventName - 'request', 'response', 'data' + * @param {Function} callback + * @returns Client instance + */ +proto.on = function (eventName, callback) { + var allowedNames = ['request', 'response', 'data'] + + if (~allowedNames.indexOf(eventName)) { + this.listeners.push([ eventName, callback ]) + } - return queryHighlight + return this } -module.exports = function (params) { - require('isomorphic-fetch') - if (!params.url) throw new Error('Missing url parameter') - - return { - query: function (query, variables) { - var headers = new Headers() - headers.append('Content-Type', 'application/json') - - return fetch(params.url, { - method: 'POST', - body: JSON.stringify({ - query: query, - variables: variables - }), - headers: headers, - credentials: params.credentials - }).then(function (res) { - return res.json() - }).then(function (data) { - if (data.errors && data.errors.length) { - throw new Error(data.errors.map(function (e) { return e.message }).join('\n') + '\n' + highlightQuery(query, data.errors)) - } - return data - }) +/** + * Emit an event. + * @param {String} eventName - 'request', 'response', 'data' + * @param {mixed} ...args + * @returns {Array} array of results received from each listener respectively + */ +proto.emit = function (eventName) { + var args = Array.prototype.slice.call(arguments, 1) + var listeners = this.listeners + var result + + // Triggering listeners and gettings latest result + for (var i = 0; i < listeners.length; i++) { + if (listeners[i][0] === eventName) { + var r = listeners[i][1].apply(this, args) + if (typeof r !== 'undefined') { + result = r + } } } + + return result } + +module.exports = function (options) { + return new Client(options) +} + +module.exports.Client = Client diff --git a/package.json b/package.json index 35e4e69..dfc8943 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,12 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "make test" }, + "files": [ + "index.js", + "README.md" + ], "repository": { "type": "git", "url": "https://github.com/nordsimon/graphql-client" @@ -15,5 +19,9 @@ "license": "ISC", "dependencies": { "isomorphic-fetch": "^2.2.1" + }, + "devDependencies": { + "chai": "^3.5.0", + "graphql": "^0.6.2" } } diff --git a/test/lib/schema.js b/test/lib/schema.js new file mode 100644 index 0000000..77da49b --- /dev/null +++ b/test/lib/schema.js @@ -0,0 +1,36 @@ +const { + GraphQLSchema, + GraphQLObjectType, + GraphQLString +} = require('graphql') + +const data = [ + { id: '1', name: 'Dan' }, + { id: '2', name: 'Marie' }, + { id: '3', name: 'Jessie' } +] + +const userType = new GraphQLObjectType({ + name: 'User', + fields: { + id: { type: GraphQLString }, + name: { type: GraphQLString } + } +}) + +const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + user: { + type: userType, + args: { + id: { type: GraphQLString } + }, + resolve: (_, args) => data.find((u) => u.id === args.id) + } + } + }) +}) + +module.exports = schema diff --git a/test/lib/server.js b/test/lib/server.js new file mode 100644 index 0000000..991142c --- /dev/null +++ b/test/lib/server.js @@ -0,0 +1,54 @@ +const http = require('http') +const schema = require('./schema') +const { graphql } = require('graphql') + +module.exports = http.createServer((req, res) => { + if (req.url === '/graphql') { + let body = '' + + req.on('data', function (data) { + body += data + }) + + req.on('end', function () { + let query = body + let variables + let operationName + + if (~req.headers['content-type'].indexOf('application/json')) { + try { + const obj = JSON.parse(query) + if (obj.query && typeof obj.query === 'string') { + query = obj.query + } + if (obj.variables !== undefined) { + variables = obj.variables + } + // Name of GraphQL operation to execute. + if (typeof obj.operationName === 'string') { + operationName = obj.operationName + } + } catch (err) { + // do nothing + } + } + + res.writeHead(200, {'content-type': 'text/json'}) + + graphql(schema, query, null, variables, operationName).then((result) => { + let response = result + + if (result.errors) { + res.statusCode = 400 + response = { + errors: result.errors.map(String) + } + } + + res.end(JSON.stringify(response)) + }).catch((e) => { + res.end(JSON.stringify(e)) + }) + }) + } +}) diff --git a/test/specs.js b/test/specs.js new file mode 100644 index 0000000..4b40c3e --- /dev/null +++ b/test/specs.js @@ -0,0 +1,157 @@ +/* eslint-env mocha */ +/* global Headers */ +const { expect } = require('chai') +const { Client } = require('..') +const server = require('./lib/server') + +const url = 'http://127.0.0.1:3099/graphql' + +describe('GraphQL client', () => { + let client + + before(() => { + server.listen(3099) + }) + + after(() => { + server.close() + }) + + beforeEach(() => { + client = new Client({ url }) + }) + + it('should query', () => { + return client.query('{ user(id: "1") { id } }').then((data) => { + expect(data).to.eql({ data: { user: { id: '1' } } }) + }) + }) + + it('should register "request" and "response" hooks', (done) => { + let counter = 0 + client + .on('request', function (req) { + counter++ + expect(this).to.be.an.instanceof(Client) + expect(req.method).to.equal('POST') + }) + .on('response', (res) => { + expect(counter).to.equal(2) + expect(res.status).to.equal(200) + + res.text().then((text) => { + expect(text).to.eql('{"data":{"user":{"id":"1"}}}') + done() + }) + }) + .on('request', (req) => { // 'request' once again + counter++ + expect(req.method).to.equal('POST') + }) + .query('{ user(id: "1") { id } }') + }) + + it('should catch "error" initiated by "response" hook', (done) => { + client + .on('response', (res) => { + throw new Error('foo') + }) + .query('{}') + .catch((e) => { + expect(e.message).to.equal('foo') + done() + }) + }) + + it('should not throw on GraphQL error in response', (done) => { + const timeout = setTimeout(() => { + done() + }, 1000) + + client.on('error', (e) => { + clearTimeout(timeout) + throw new Error('"error" event triggered') + }) + .query('{}') + }) + + it('should modify request before querying', (done) => { + const headers = new Headers() + headers.set('content-length', 3) + + const request = { + method: 'GET', + credentials: 'include', + headers + } + + const client = new Client({ url, request }) + + client.on('request', (req) => { + expect(req.method).to.equal('GET') + expect(req.credentials).to.equal('include') + expect(req.headers.get('content-type')).to.be.falsy + expect(req.headers.get('content-length')).to.equal(3) + done() + }) + .query('{}') + }) + + it('should modify req using "beforeRequest" function', (done) => { + client.on('request', (req) => { + expect(req.method).to.equal('GET') + done() + }) + .query('{}', null, (req) => { + req.method = 'GET' + }) + }) + + it('should redefine response through "beforeRequest" hook', () => { + return client + .query('{}', null, () => 'foo') + .then((r) => expect(r).to.equal('foo')) + }) + + it('should redefine response using "request" hook', () => { + return client + .on('request', () => 'foo') + .query('{}') + .then((r) => expect(r).to.equal('foo')) + }) + + it('should redefine response using result from latest "request" hook', () => { + return client + .on('request', () => 'foo') + .on('request', () => 'bar') + .on('request', () => 'baz') + .query('{}') + .then((r) => expect(r).to.equal('baz')) + }) + + it('should redefine `data` using "data" hook', () => { + return client + .on('data', (data) => data.data) + .query('{ user(id: "1") { id } }') + .then((r) => expect(r).to.eql({ user: { id: '1' } })) + }) + + it('should redefine using both "res" and "data" hooks', () => { + return client + .on('request', () => 'foo') + .on('data', (data) => (data += '-bar')) + .query('{}') + .then((r) => expect(r).to.eql('foo-bar')) + }) + + it('should redefine using "beforeRequest" and skipp other hooks', () => { + return client + // never triggers + .on('request', (req) => 'baz') + // never triggers + .on('data', (data) => 'bar') + // triggers + .query('{}', null, (req) => 'foo') + .then((r) => expect(r).to.eql('foo')) + }) +})