From e01c67ed81f281406736e1c256afaf4e8acd4f24 Mon Sep 17 00:00:00 2001 From: Andrew Wong Date: Sat, 7 Sep 2019 18:42:15 +1000 Subject: [PATCH 1/3] * Removed synchronous functions in favor of ES6 Promises * Created Client class to allow for multiple API instances * Better support for error handling (async + throw issues) --- README.md | 5 +- lib/client.js | 138 +++++++++++++++++++++---------------------------- lib/elvanto.js | 125 +++++++++++++++++++------------------------- lib/error.js | 52 +++++++------------ lib/utils.js | 29 ----------- package.json | 15 +++--- 6 files changed, 140 insertions(+), 224 deletions(-) delete mode 100644 lib/utils.js diff --git a/README.md b/README.md index 7a32e93..5e13525 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Elvanto API Node.js Library +> Version 1.0.2 + This library is all set to go with version 1 of the Elvanto API. ## Installation @@ -14,10 +16,9 @@ npm install elvanto-api The Elvanto API supports authentication using either OAuth 2 or an API key. -### What is This For? +### What Is This For? * This is an API wrapper to use in conjunction with an Elvanto account. This wrapper can be used by developers to develop programs for their own churches, or to design integrations to share to other churches using OAuth authentication. -* Version 1.0.0 ### Using OAuth 2 diff --git a/lib/client.js b/lib/client.js index 1b2d7eb..c4d034e 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1,93 +1,73 @@ -var discoverError = require('./error.js'); -var https = require("https"); -var syncHttp = require("urllib-sync") -var querystring = require("querystring"); -var utils = require('./utils.js'); +const discoverError = require('./error.js') +const querystring = require('querystring') +const fetch = require('node-fetch') -var config = { - host: "api.elvanto.com", - port: 443, - method: "post", - headers: {} +const config = { + host: 'api.elvanto.com' } -var options = null - -// HTTP post request -var post = function(path, data, callback){ - return callback ? request(path, data, callback) : syncRequest(path, data) -} - -// Make an api call to Elvanto endpoint -// @param [String] resource Path to Elvanto endpoint -// @param [Object] data -// @param [Function] callback. If callback is not present, then call will be synchronous -// @return [Object] response body -var apiCall = function(resource, data, callback){ - options = config; - options["headers"]["Content-Type"] = "application/json"; - return post(resource, JSON.stringify(data || {}), callback) +class Client { + constructor (authData = {}) { + this.auth = null + this.options = { headers: {} } + + this.configure(authData) + } + + // @params [Object] params. + // When using OAuth authentication it is {accessToken: "accessToken"} + // When using an API key it is {apiKey: "apiKey"} + configure ({ accessToken, apiKey }) { + if (accessToken) { + this.auth = { accessToken } + this.options['headers']['Authorization'] = `Bearer ${accessToken}` + } else if (apiKey) { + this.auth = { apiKey } + let B64auth = Buffer.from(`${apiKey}:x`).toString('base64') + this.options['headers']['Authorization'] = `Basic ${B64auth}` + } + + return this + } + + // @param [String] endPoint for example: "people/getAll" or "groups/GetInfo" + // @param [Object] option List of parameters + // @return [Object] response body + apiCall (endPoint, data) { + if (!this.auth) throw new Error('Not configured - Please provide an access token or an API key') + + let headers = { 'Content-Type': 'application/json' } + + return post(`https://${config.host}/v1/${endPoint}.json`, JSON.stringify(data || {}), { ...this.options['headers'], headers }) + } } - // Retrieve tokens // @param [Object] data -// @param [Function] callback. If callback is not present, then call will be synchronous -// @return [Object] {access_token: accessToken, expiresIn, refreshToken} -var retrieveTokens = function(data, callback){ - options = config; - options["headers"]["Content-Type"] = "application/x-www-form-urlencoded"; - return post("/oauth/token", querystring.stringify(data || {}), callback) -} - +// @return [Object] {access_token, expires_in, refresh_token} +let retrieveTokens = function (data) { + let headers = { 'Content-Type': 'application/x-www-form-urlencoded' } -// Asynchronous request -var request = function(path, data, callback){ - options["path"] = path; - - var request = https.request(options, function (response) { - var payload = "" - response.on('data', function(chunk){ - payload += chunk; - }); - response.on('end', function() { - body = payload ? JSON.parse(payload) : {} ; - - // Throws an exception if error found - discoverError(response.statusCode, body); - - callback(body); - }); - }); - request.on('error', function (error) { - console.log(error.message); - }); - - request.write(data); - request.end(); - - return request; + return post(`https://${config.host}/oauth/token`, querystring.stringify(data || {}), headers) } -// Synchronous request -var syncRequest = function(path, data, callback){ - var url = utils.buildURL("https", options["host"], path); - options["data"] = data; - options["dataType"] = "json"; - options["timeout"] = 20000; - - var response = syncHttp.request(url, options); - var body = response.data; - - // Throws an exception if error found - discoverError(response.statusCode, body); - - return body; +const post = function (path, data, headers) { + return fetch(path, { + method: 'POST', + body: data + }).then(response => { + try { + discoverError(response.status) + } catch (err) { + throw err + } + + return response.json() + }) } module.exports = { - config: config, - apiCall: apiCall, - retrieveTokens: retrieveTokens + Client, + config, + retrieveTokens } - diff --git a/lib/elvanto.js b/lib/elvanto.js index e4f1e65..7ceff45 100644 --- a/lib/elvanto.js +++ b/lib/elvanto.js @@ -1,95 +1,76 @@ -// Node JS wrapper for Elvanto API. -// Can work in both asynchronous over synchronous mode +// Asynchronous Node.js wrapper for Elvanto API. -var utils = require('./utils.js'); -var client = require('./client.js'); - -const DEFAULT_OPTIONS = { - apiVersion: "/v1/", - accept: "json" -}; - -var options = {}; - -// @params [Object] params. In case of Oauth athentication it is {accessToken: "accessToken"}. -// When authenticating with an API key it {apiKey: "apiKey"} -exports.configure = function(params){ - options = utils.mergeOptions(DEFAULT_OPTIONS, params); - - if (options["apiKey"]){ - client.config["auth"] = options["apiKey"] + ':x' - } - else if (options["accessToken"]){ - client.config["headers"] = {"Authorization": "Bearer " + options["accessToken"]} - } - else { - throw new Error('You should provide either access token or API key'); - } - - return client.config; -}; +const client = require('./client.js') +const querystring = require('querystring') // @params [String] clientId The Client ID of your registered OAuth application. // @params [String] redirectUri The Redirect URI of your registered OAuth application. // @params [String] scope // @params [String] state Optional state data to be included in the URL. // @return [String] The authorization URL to which users of your application should be redirected. -exports.authorizeUrl = function(clientId, redirectUri, scope, state){ - if (scope instanceof Array){ - scope = scope.join(); - } +const authorizeUrl = function (clientId, redirectUri, scope, state) { + if (typeof clientId === 'undefined') throw new Error('clientId is required') + if (typeof redirectUri === 'undefined') throw new Error('redirectUri is required') + + if (scope instanceof Array) { + scope = scope.join() + } - params = {type: "web_server", client_id: clientId, redirect_uri: redirectUri, scope: scope} + let params = { + type: 'web_server', + client_id: clientId, + redirect_uri: redirectUri, + scope: scope + } - if (state) { - params["state"] = state; - } + if (state) { + params['state'] = state + } - return utils.buildURL("https", client.config["host"], "oauth", params) -}; + let url = `https://${client.config['host']}/oauth?${querystring.stringify(params)}` + + return url +} // @params [String] clientId The Client ID of your registered OAuth application. // @param [String] clientSecret The Client Secret of your registered OAuth application. // @param [String] code The unique OAuth code to be exchanged for an access token. // @param [String] redirectUrl The Redirect URI of your registered OAuth application. -// @param [function] callback. If callback is not present, then call will be synchronous // @return [Object] {access_token: accessToken, expiresIn, refreshToken} -exports.exchangeToken = function(clientId, clientSecret, code, redirectUri, callback){ - data = {"grant_type": 'authorization_code', "client_id": clientId, "client_secret": clientSecret, "code": code, "redirect_uri": redirectUri}; - return client.retrieveTokens(data, callback); -}; +const exchangeToken = function (clientId, clientSecret, code, redirectUri) { + if (typeof clientId === 'undefined') throw new Error('clientId is required') + if (typeof clientSecret === 'undefined') throw new Error('clientSecret is required') + if (typeof code === 'undefined') throw new Error('code is required') + if (typeof redirectUri === 'undefined') throw new Error('redirectUri is required') + + let data = { + grant_type: 'authorization_code', + client_id: clientId, + client_secret: clientSecret, + code: code, + redirect_uri: redirectUri + } + + return client.retrieveTokens(data) +} // @param [String] refreshToken Was included when the original token was granted to automatically retrieve a new access token. -// @param [Function] callback. If callback is not present, then call will be synchronous // @return [Object] {access_token: accessToken, expiresIn, refreshToken} -exports.refreshToken = function(refreshToken, callback){ - if (typeof refreshToken === 'undefined') - throw new Error('Error refreshing token. There is no refresh token set on this object'); +const refreshToken = function (refreshToken) { + if (typeof refreshToken === 'undefined') { + throw new Error( + 'Error refreshing token. There is no refresh token set' + ) + } - data = {grant_type: "refresh_token", refresh_token: refreshToken}; - return client.retrieveTokens(data, callback); -} + let data = { grant_type: 'refresh_token', refresh_token: refreshToken } -// @param [String] resource -// @return [String] path to resource -var resourcePath = function(resource){ - if (!options["apiVersion"] || !options["accept"]){ - throw new Error('Most probably you forgot to call configure function'); - } - - return options["apiVersion"] + resource + "." + options["accept"] -}; - -// @param [String] endPoint for example: "people/getAll" or "groups/GetInfo" -// @param [Object] option List of parametrs -// @param [Function] callback for response body. If callback is not present, then call will be synchronous -// @return [Object] response body -exports.apiCall = function(endPoint, data, callback){ - return client.apiCall(resourcePath(endPoint), data, callback); + return client.retrieveTokens(data) } - - - - - +module.exports = { + Client: client.Client, + authorizeUrl, + exchangeToken, + refreshToken +} diff --git a/lib/error.js b/lib/error.js index 166a236..5ee3808 100644 --- a/lib/error.js +++ b/lib/error.js @@ -1,40 +1,24 @@ const CATEGORY_CODE_MAP = { - "50": "Elvanto::Unauthorized: ", - "100": "Elvanto::Unauthorized: ", - "102": "Elvanto::Unauthorized: ", - "250": "Elvanto::BadRequest: ", - "404": "Elvanto::NotFound: ", - "500": "Elvanto::InternalError: " -}; + 50: 'Elvanto::Unauthorized:', + 100: 'Elvanto::Unauthorized:', + 102: 'Elvanto::Unauthorized:', + 250: 'Elvanto::BadRequest:' +} const HTTP_STATUS_CODES = { - "401": "Elvanto::Unauthorized: ", - "400": "Elvanto::BadRequest: ", - "404": "Elvanto::NotFound: ", - "500": "Elvanto::InternalError: " -}; - -// Throws an exception if response has bad status code or response's body contains an error message. -var discoverError = function(status_code, body){ - if (body.error_description){ - handleError(status_code, body.error_description); - } - else if (body.error){ - handleError(body.error.code, body.error.message); - } - return true; + 401: 'Elvanto::Unauthorized:', + 400: 'Elvanto::BadRequest:', + 404: 'Elvanto::NotFound:', + 500: 'Elvanto::InternalError:' } -var handleError = function(code, message){ - if (CATEGORY_CODE_MAP[code]){ - throw new Error(CATEGORY_CODE_MAP[code] + message); - } - else if(HTTP_STATUS_CODES[code]){ - throw new Error(HTTP_STATUS_CODES[code] + message); - } - else { - throw new Error(message); - } -}; +let ERROR_CODES = { ...CATEGORY_CODE_MAP, ...HTTP_STATUS_CODES } + +// Throws an exception if response has bad status code +const discoverError = function (code) { + if (ERROR_CODES[code]) { + throw new Error(ERROR_CODES[code]) + } +} -module.exports = discoverError; \ No newline at end of file +module.exports = discoverError diff --git a/lib/utils.js b/lib/utils.js deleted file mode 100644 index 981206f..0000000 --- a/lib/utils.js +++ /dev/null @@ -1,29 +0,0 @@ -var querystring = require("querystring") - - -/** - * Overwrites obj1's values with obj2's and adds obj2's if non existent in obj1 - * @param obj1 - * @param obj2 - * @returns obj3 a new object based on obj1 and obj2 - */ -var mergeOptions = function(obj1,obj2){ - var obj3 = {}; - for (var attrname in obj1) { obj3[attrname] = obj1[attrname]; } - for (var attrname in obj2) { obj3[attrname] = obj2[attrname]; } - return obj3; -} - -var buildURL = function(schema, host, path, params){ - var url = schema + "://" + host + "/" + path; - if(params){ - url = url + "?" + querystring.stringify(params) - } - - return url -} - -module.exports = { - buildURL: buildURL, - mergeOptions: mergeOptions -} \ No newline at end of file diff --git a/package.json b/package.json index eea141f..ec37acf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "elvanto-api", - "version": "1.0.1", + "version": "1.0.2", "description": "API wrapper for use in conjunction with an Elvanto account. This wrapper can be used by developers to develop programs for their own churches using an API Key, or to design integrations to share to other churches using OAuth authentication.", "main": "./lib/elvanto.js", "keywords": [ @@ -8,15 +8,14 @@ "api", "node", "nodejs" - ], - "dependencies": { - "https" : "*", - "urllib-sync": "*", + ], + "dependencies": { + "node-fetch": "^2.6.0", "querystring": "*" }, - "engines" : { - "node": ">=0.11.13" - }, + "engines": { + "node": ">=0.11.13" + }, "author": "Elvanto", "license": "MIT" } From 9c87bbfda41574b5b35608d7db1bc5addebd184d Mon Sep 17 00:00:00 2001 From: Andrew Wong Date: Sat, 7 Sep 2019 19:38:42 +1000 Subject: [PATCH 2/3] Update error checking --- lib/client.js | 45 +++++++++++++++++++++++++++++++-------------- lib/elvanto.js | 4 +--- lib/error.js | 7 +++---- 3 files changed, 35 insertions(+), 21 deletions(-) diff --git a/lib/client.js b/lib/client.js index c4d034e..e8c4bac 100644 --- a/lib/client.js +++ b/lib/client.js @@ -9,7 +9,11 @@ const config = { class Client { constructor (authData = {}) { this.auth = null - this.options = { headers: {} } + this.options = { + headers: { + Accept: 'application/json' + } + } this.configure(authData) } @@ -34,11 +38,19 @@ class Client { // @param [Object] option List of parameters // @return [Object] response body apiCall (endPoint, data) { - if (!this.auth) throw new Error('Not configured - Please provide an access token or an API key') + if (!this.auth) { + throw new Error( + 'Not configured - Please provide an access token or an API key' + ) + } let headers = { 'Content-Type': 'application/json' } - return post(`https://${config.host}/v1/${endPoint}.json`, JSON.stringify(data || {}), { ...this.options['headers'], headers }) + return post( + `https://${config.host}/v1/${endPoint}.json`, + JSON.stringify(data || {}), + { ...this.options['headers'], headers } + ) } } @@ -48,22 +60,27 @@ class Client { let retrieveTokens = function (data) { let headers = { 'Content-Type': 'application/x-www-form-urlencoded' } - return post(`https://${config.host}/oauth/token`, querystring.stringify(data || {}), headers) + return post( + `https://${config.host}/oauth/token`, + querystring.stringify(data || {}), + headers + ) } const post = function (path, data, headers) { return fetch(path, { method: 'POST', - body: data - }).then(response => { - try { - discoverError(response.status) - } catch (err) { - throw err - } - - return response.json() - }) + body: data, + headers + }).then(response => + response.json().then(json => { + try { + discoverError(response.status, json) + } catch (err) { + throw err + } + }) + ) } module.exports = { diff --git a/lib/elvanto.js b/lib/elvanto.js index 7ceff45..6449e9d 100644 --- a/lib/elvanto.js +++ b/lib/elvanto.js @@ -58,9 +58,7 @@ const exchangeToken = function (clientId, clientSecret, code, redirectUri) { // @return [Object] {access_token: accessToken, expiresIn, refreshToken} const refreshToken = function (refreshToken) { if (typeof refreshToken === 'undefined') { - throw new Error( - 'Error refreshing token. There is no refresh token set' - ) + throw new Error('No refresh token given') } let data = { grant_type: 'refresh_token', refresh_token: refreshToken } diff --git a/lib/error.js b/lib/error.js index 5ee3808..7242bff 100644 --- a/lib/error.js +++ b/lib/error.js @@ -15,10 +15,9 @@ const HTTP_STATUS_CODES = { let ERROR_CODES = { ...CATEGORY_CODE_MAP, ...HTTP_STATUS_CODES } // Throws an exception if response has bad status code -const discoverError = function (code) { - if (ERROR_CODES[code]) { - throw new Error(ERROR_CODES[code]) - } +const discoverError = function (code, resp) { + if (ERROR_CODES[code]) throw new Error(ERROR_CODES[code]) + if (resp.error_description) throw new Error(`Elvanto::${resp.error}:${resp.error_description}`) } module.exports = discoverError From f605c0388eebe891aa818c8fb36a251e2c6ac5a1 Mon Sep 17 00:00:00 2001 From: Andrew Wong Date: Sat, 7 Sep 2019 20:08:37 +1000 Subject: [PATCH 3/3] Fix: Return response for POST --- lib/client.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/client.js b/lib/client.js index e8c4bac..9db252d 100644 --- a/lib/client.js +++ b/lib/client.js @@ -79,6 +79,7 @@ const post = function (path, data, headers) { } catch (err) { throw err } + return json }) ) }