From 74ccfe7c0cd408ff5c626c49d72fdf8ff548ae3c Mon Sep 17 00:00:00 2001 From: kevingrozav Date: Tue, 12 Sep 2017 00:41:42 -0700 Subject: [PATCH] Update to Async/Await --- .babelrc | 21 ++++- .eslintrc | 3 +- package.json | 10 +- src/app.js | 194 +++++++++++++++++++------------------- src/oauth.js | 48 +++++----- src/ssl.js | 45 +++++---- src/test/test.js | 239 +++++++++++++++++++++++------------------------ 7 files changed, 286 insertions(+), 274 deletions(-) diff --git a/.babelrc b/.babelrc index 8da4517..3f824f9 100644 --- a/.babelrc +++ b/.babelrc @@ -1,6 +1,19 @@ { + "presets": [ + ["env", { + "targets": { + "node": "0.12" + } + }] + ], "compact": false, - "presets": ["es2015"], - "auxiliaryCommentBefore": "istanbul ignore next", - "sourceMaps": "inline" -} + "sourceMaps": "inline", + "plugins": [ + "transform-async-to-bluebird", + "transform-promise-to-bluebird", + ["transform-runtime", { + "polyfill": false, + "regenerator": true + }] + ] +} \ No newline at end of file diff --git a/.eslintrc b/.eslintrc index 8df42c4..4f46faf 100644 --- a/.eslintrc +++ b/.eslintrc @@ -17,7 +17,8 @@ globals: parserOptions: sourceType: "module" - + ecmaVersion: 8 + rules: # ERRORS space-before-blocks: 2 diff --git a/package.json b/package.json index 77f4c92..a6f09f9 100644 --- a/package.json +++ b/package.json @@ -37,18 +37,24 @@ "cibuild": "npm run build" }, "dependencies": { + "babel-preset-env": "^1.5.1", "babel-preset-es2015": "^6.6.0", + "bluebird": "^3.5.0", "body-parser": "^1.15.2", + "chai-as-promised": "^7.1.1", "debug": "^2.2.0", "express": "^4.14.0", "jsonwebtoken": "^7.1.9", "request": "^2.73.0" }, "devDependencies": { - "babel-cli": "^6.10.1", "chai": "^3.5.0", "eslint": "^3.3.0", - "mocha": "^2.5.3" + "mocha": "^2.5.3", + "babel-cli": "^6.24.1", + "babel-plugin-transform-async-to-bluebird": "^1.1.1", + "babel-plugin-transform-promise-to-bluebird": "^1.1.1", + "babel-plugin-transform-runtime": "^6.23.0" }, "engines": { "node": ">=6.9.1", diff --git a/src/app.js b/src/app.js index 67cca72..4afb4eb 100644 --- a/src/app.js +++ b/src/app.js @@ -11,13 +11,16 @@ import * as https from 'https'; import * as oauth from './oauth'; import * as ssl from './ssl'; import debug from 'debug'; +import Promise from 'bluebird'; // Debug log const log = debug('watsonwork-echo-app'); +const post = Promise.promisify(request.post); + // Echoes Watson Work chat messages containing 'hello' or 'hey' back // to the space they were sent to -export const echo = (appId, token) => (req, res) => { +export const echo = (appId, token) => async (req, res) => { // Respond to the Webhook right away, as the response message will // be sent asynchronously res.status(201).end(); @@ -38,52 +41,58 @@ export const echo = (appId, token) => (req, res) => { .filter((word) => /^(hello|hey)$/i.test(word)).length) // Send the echo message - send(req.body.spaceId, + await send(req.body.spaceId, util.format( 'Hey %s, did you say %s?', req.body.userName, req.body.content), - token(), - (err, res) => { - if(!err) - log('Sent message to space %s', req.body.spaceId); - }); + token()); + log('Sent message to space %s', req.body.spaceId); }; // Send an app message to the conversation in a space -const send = (spaceId, text, tok, cb) => { - request.post( - 'https://api.watsonwork.ibm.com/v1/spaces/' + spaceId + '/messages', { - headers: { - Authorization: 'Bearer ' + tok - }, - json: true, - // An App message can specify a color, a title, markdown text and - // an 'actor' useful to show where the message is coming from - body: { - type: 'appMessage', - version: 1.0, - annotations: [{ - type: 'generic', +const send = async (spaceId, text, tok) => { + let res; + try { + res = await post( + 'https://api.watsonwork.ibm.com/v1/spaces/' + spaceId + '/messages', { + headers: { + Authorization: 'Bearer ' + tok + }, + json: true, + // An App message can specify a color, a title, markdown text and + // an 'actor' useful to show where the message is coming from + body: { + type: 'appMessage', version: 1.0, - - color: '#6CB7FB', - title: 'Echo message', - text: text, - - actor: { - name: 'Sample echo app' - } - }] - } - }, (err, res) => { - if(err || res.statusCode !== 201) { - log('Error sending message %o', err || res.statusCode); - cb(err || new Error(res.statusCode)); - return; - } + annotations: [{ + type: 'generic', + version: 1.0, + + color: '#6CB7FB', + title: 'Echo message', + text: text, + + actor: { + name: 'Sample echo app' + } + }] + } + }); + + // Handle invalid status response code + if (res.statusCode !== 201) + throw new Error(res.statusCode); + + // log the valid response and its body + else log('Send result %d, %o', res.statusCode, res.body); - cb(null, res.body); - }); + } + catch(err) { + // log the error and rethrow it + log('Error sending message %o', err); + throw err; + } + return res; }; // Verify Watson Work request signature @@ -113,73 +122,64 @@ export const challenge = (wsecret) => (req, res, next) => { }; // Create Express Web app -export const webapp = (appId, secret, wsecret, cb) => { +export const webapp = async (appId, secret, wsecret) => { // Authenticate the app and get an OAuth token - oauth.run(appId, secret, (err, token) => { - if(err) { - cb(err); - return; - } - - // Return the Express Web app - cb(null, express() - - // Configure Express route for the app Webhook - .post('/echo', + const token = await oauth.run(appId, secret); + // Configure Express route for the app Webhook + return express().post('/echo', - // Verify Watson Work request signature and parse request body - bparser.json({ - type: '*/*', - verify: verify(wsecret) - }), + // Verify Watson Work request signature and parse request body + bparser.json({ + type: '*/*', + verify: verify(wsecret) + }), - // Handle Watson Work Webhook challenge requests - challenge(wsecret), + // Handle Watson Work Webhook challenge requests + challenge(wsecret), - // Handle Watson Work messages - echo(appId, token))); - }); + // Handle Watson Work messages + echo(appId, token)); }; // App main entry point -const main = (argv, env, cb) => { - // Create Express Web app - webapp( - env.ECHO_APP_ID, env.ECHO_APP_SECRET, - env.ECHO_WEBHOOK_SECRET, (err, app) => { - if(err) { - cb(err); - return; - } - - if(env.PORT) { - // In a hosting environment like Bluemix for example, HTTPS is - // handled by a reverse proxy in front of the app, just listen - // on the configured HTTP port - log('HTTP server listening on port %d', env.PORT); - http.createServer(app).listen(env.PORT, cb); - } - - else - // Listen on the configured HTTPS port, default to 443 - ssl.conf(env, (err, conf) => { - if(err) { - cb(err); - return; - } - const port = env.SSLPORT || 443; - log('HTTPS server listening on port %d', port); - https.createServer(conf, app).listen(port, cb); - }); - }); +const main = async (argv, env) => { + try { + // Create Express Web app + const app = await webapp( + env.ECHO_APP_ID, env.ECHO_APP_SECRET, + env.ECHO_WEBHOOK_SECRET); + if(env.PORT) { + // In a hosting environment like Bluemix for example, HTTPS is + // handled by a reverse proxy in front of the app, just listen + // on the configured HTTP port + log('HTTP server listening on port %d', env.PORT); + http.createServer(app).listen(env.PORT); + } + + else { + + // Listen on the configured HTTPS port, default to 443 + const sslConfig = await ssl.conf(env); + const port = env.SSLPORT || 443; + log('HTTPS server listening on port %D', port); + https.createServer(sslConfig, app).listen(port); + } + } + catch(err) { + throw err; + } }; -if (require.main === module) - main(process.argv, process.env, (err) => { - if(err) { +// Run main as IIFE (top level await not supported) +(async () => { + if (require.main === module) + try { + await main(process.argv, process.env); + log('App started!'); + } + catch(err) { console.log('Error starting app:', err); - return; } - log('App started'); - }); +})(); + diff --git a/src/oauth.js b/src/oauth.js index d35eb9e..624ac06 100644 --- a/src/oauth.js +++ b/src/oauth.js @@ -1,29 +1,32 @@ // Regularly obtain a fresh OAuth token for the app - import * as request from 'request'; import * as jsonwebtoken from 'jsonwebtoken'; import debug from 'debug'; +import Promise from 'bluebird'; // Setup debug log const log = debug('watsonwork-echo-oauth'); +// Promisify request's post function +const post = Promise.promisify(request.post); + + // Obtain an OAuth token for the app, repeat at regular intervals before the // token expires. Returns a function that will always return a current // valid token. -export const run = (appId, secret, cb) => { +export const run = async (appId, secret) => { let tok; // Return the current token const current = () => tok; // Return the time to live of a token - const ttl = (tok) => + const ttl = (tok) => Math.max(0, jsonwebtoken.decode(tok).exp * 1000 - Date.now()); - // Refresh the token - const refresh = (cb) => { + const refresh = async () => { log('Getting token'); - request.post('https://api.watsonwork.ibm.com/oauth/token', { + const res = await post('https://api.watsonwork.ibm.com/oauth/token', { auth: { user: appId, pass: secret @@ -32,28 +35,27 @@ export const run = (appId, secret, cb) => { form: { grant_type: 'client_credentials' } - }, (err, res) => { - if(err || res.statusCode !== 200) { - log('Error getting token %o', err || res.statusCode); - cb(err || new Error(res.statusCode)); - return; - } + }); - // Save the fresh token - log('Got new token'); - tok = res.body.access_token; + // check the status code of the result + if (res.statusCode !== 200) + throw new Error(res.statusCode); + + // Save the fresh token + log('Got new token'); + tok = res.body.access_token; - // Schedule next refresh a bit before the token expires - const t = ttl(tok); - log('Token ttl', t); - setTimeout(refresh, Math.max(0, t - 60000)).unref(); + // Schedule next refresh a bit before the token expires + const t = ttl(tok); + log('Token ttl', t); + setTimeout(refresh, Math.max(0, t - 60000)).unref(); - // Return a function that'll return the current token - cb(undefined, current); - }); + + // return the current token + return current; }; // Obtain initial token - setImmediate(() => refresh(cb)); + return await refresh(); }; diff --git a/src/ssl.js b/src/ssl.js index 8de7e74..685a22e 100644 --- a/src/ssl.js +++ b/src/ssl.js @@ -4,31 +4,30 @@ import * as fs from 'fs'; import debug from 'debug'; - +import Promise from 'bluebird'; // Debug log const log = debug('watsonwork-echo-ssl'); + +// Promisify filesystem readfile for readablility +const readFile = Promise.promisify(fs.readFile); + // Return HTTPS server SSL configuration -export const conf = (env, cb) => { - // Read configured SSL cert and key - log('Reading SSL cert'); - fs.readFile(env.SSLCERT || './server.crt', (err, cert) => { - if(err) { - log('Error reading SSL cert %o', err); - cb(err); - return; - } - fs.readFile(env.SSLKEY || './server.key', (err, key) => { - if(err) { - log('Error reading SSL key %o', err); - cb(err); - return; - } - cb(null, { - cert: cert, - key: key - }); - }); - }); +export const conf = async (env) => { + + // Try reading the cert and key + try { + const cert = await readFile(env.SSLCERT || './server.cert'); + const key = await readFile(env.SSLKEY, './server.key'); + return { + cert: cert, + key: key + }; + } + catch(err) { + log('Error reading SSL cert or key %o', err); + throw err; + } + // the ssl certificate was not successfully created + return new Error('Unable to create SSL configuration'); }; - diff --git a/src/test/test.js b/src/test/test.js index 6e32598..f993ea5 100644 --- a/src/test/test.js +++ b/src/test/test.js @@ -6,8 +6,13 @@ import { expect } from 'chai'; import * as jsonwebtoken from 'jsonwebtoken'; import { post } from 'request'; +import Promise from 'bluebird'; + +// Promisify Request.post BEFORE overwrite +const postAsync = Promise.promisify(post); // Rudimentary mock of the request module +// This overwrites Request.post let postspy; require.cache[require.resolve('request')].exports = { post: (uri, opt, cb) => postspy(uri, opt, cb) @@ -18,7 +23,6 @@ const echo = require('../app'); // Generate a test OAuth token const token = jsonwebtoken.sign({}, 'secret', { expiresIn: '1h' }); - describe('watsonwork-echo', () => { // Mock the Watson Work OAuth service @@ -41,97 +45,84 @@ describe('watsonwork-echo', () => { })); }; - it('authenticates the app', (done) => { - - // Check async callbacks - let checks = 0; - const check = () => { - if(++checks === 2) - done(); - }; - + it('authenticates the app', async () => { + postspy = (uri, opt, cb) => { // Expect a call to get an OAuth token for the app if(uri === 'https://api.watsonwork.ibm.com/oauth/token') { oauth(uri, opt, cb); - check(); return; } }; + + try { + // Create the Echo Web app + const app = await echo.webapp('testappid', 'testsecret', 'testwsecret'); + expect(app).to.not.equal(null); + expect(app).to.not.equal(undefined); + } + catch(err) { + throw err; + } - // Create the Echo Web app - echo.webapp('testappid', 'testsecret', 'testwsecret', (err, app) => { - expect(err).to.equal(null); - check(); - }); }); - it('handles Webhook challenge requests', (done) => { - // Check async callbacks - let checks = 0; - const check = () => { - if(++checks === 2) - done(); - }; + it('handles Webhook challenge requests', async () => { postspy = (uri, opt, cb) => { // Expect a call to get an OAuth token for the app if(uri === 'https://api.watsonwork.ibm.com/oauth/token') { oauth(uri, opt, cb); - check(); return; } }; - - // Create the Echo Web app - echo.webapp('testappid', 'testsecret', 'testwsecret', (err, app) => { - expect(err).to.equal(null); + + try { + // Create the echo web app + const app = await echo.webapp('testappid', 'testsecret', 'testwsecret'); + expect(app).to.not.equal(null); + expect(app).to.not.equal(undefined); // Listen on an ephemeral port const server = app.listen(0); + + // Post Webhook challenge request to the app + const res = await postAsync( + 'http://localhost:' + server.address().port + '/echo', { + headers: { + // Signature of the test body with the Webhook secret + 'X-OUTBOUND-TOKEN': + 'f51ff5c91e99c63b6fde9e396bb6ea3023727f74f1853f29ab571cfdaaba4c03' + }, + json: true, + body: { + type: 'verification', + challenge: 'testchallenge' + } + }); - // Post a Webhook challenge request to the app - post('http://localhost:' + server.address().port + '/echo', { - headers: { - // Signature of the test body with the Webhook secret - 'X-OUTBOUND-TOKEN': - 'f51ff5c91e99c63b6fde9e396bb6ea3023727f74f1853f29ab571cfdaaba4c03' - }, - json: true, - body: { - type: 'verification', - challenge: 'testchallenge' - } - }, (err, res) => { - expect(err).to.equal(null); - expect(res.statusCode).to.equal(200); - - // Expect correct challenge response and signature - expect(res.body.response).to.equal('testchallenge'); - expect(res.headers['x-outbound-token']).to.equal( - // Signature of the test body with the Webhook secret - '876d1f9de1b36514d30bcf48d8c4731a69500730854a964e31764159d75b88f1'); - - check(); - }); - }); + // Check that state of the response + expect(res.statusCode).to.equal(200); + + // Expect correct challenge response and signature + expect(res.body.response).to.equal('testchallenge'); + expect(res.headers['x-outbound-token']).to.equal( + // Signature of the test body with the Webhook secret + '876d1f9de1b36514d30bcf48d8c4731a69500730854a964e31764159d75b88f1'); + } + catch(err) { + throw err; + } + }); - it('Echoes messages back', (done) => { - - // Check async callbacks - let checks = 0; - const check = () => { - if(++checks === 3) - done(); - }; + it('Echoes messages back', async () => { postspy = (uri, opt, cb) => { // Expect a call to get the OAuth token of an app if(uri === 'https://api.watsonwork.ibm.com/oauth/token') { oauth(uri, opt, cb); - check(); return; } @@ -164,88 +155,88 @@ describe('watsonwork-echo', () => { body: { } })); - check(); } }; - // Create the Echo Web app - echo.webapp('testappid', 'testsecret', 'testwsecret', (err, app) => { - expect(err).to.equal(null); + try { + // Create the echo web app + const app = await echo.webapp('testappid', 'testsecret', 'testwsecret'); + expect(app).to.not.equal(null); + expect(app).to.not.equal(undefined); // Listen on an ephemeral port const server = app.listen(0); - + // Post a chat message to the app - post('http://localhost:' + server.address().port + '/echo', { - headers: { - 'X-OUTBOUND-TOKEN': - // Signature of the body with the Webhook secret - '7b36f68c9ef83e62c154d7f5eaad634947f1e92931ac213462f489d7d8f8bcad' - }, - json: true, - body: { - type: 'message-created', - content: 'Hello there', - userName: 'Jane', - spaceId: 'testspace' - } - }, (err, val) => { - expect(err).to.equal(null); - expect(val.statusCode).to.equal(201); - - check(); - }); - }); - }); + const val = await postAsync( + 'http://localhost:' + server.address().port + '/echo', { + headers: { + 'X-OUTBOUND-TOKEN': + // Signature of the body with the Webhook secret + '7b36f68c9ef83e62c154d7f5eaad634947f1e92931ac213462f489d7d8f8bcad' + }, + json: true, + body: { + type: 'message-created', + content: 'Hello there', + userName: 'Jane', + spaceId: 'testspace' + } + }); - it('rejects messages with invalid signature', (done) => { + // check successful response status + expect(val.statusCode).to.equal(201); + } + catch(err) { + throw err; + } + }); - // Check async callbacks - let checks = 0; - const check = () => { - if(++checks === 2) - done(); - }; + it('rejects messages with invalid signature', async () => { postspy = (uri, opt, cb) => { // Expect a call to get an OAuth token for the app if(uri === 'https://api.watsonwork.ibm.com/oauth/token') { oauth(uri, opt, cb); - check(); + // check(); return; } }; - // Create the Echo Web app - echo.webapp('testappid', 'testsecret', 'testwsecret', (err, app) => { - expect(err).to.equal(null); - - // Listen on an ephemeral port + try { + // Create the echo web app + const app = await echo.webapp('testappid', 'testsecret', 'testwsecret'); + expect(app).to.not.equal(null); + expect(app).to.not.equal(undefined); + + // create the server on an ephemeral port const server = app.listen(0); + + // Send a request with invalid signature to be rejected + const val = await postAsync( + 'http://localhost:' + server.address().port + '/echo', { + headers: { + 'X-OUTBOUND-TOKEN': + // Test an invalid body signature + 'invalidsignature' + }, + json: true, + body: { + type: 'message-created', + content: 'Hello there', + userName: 'Jane', + spaceId: 'testspace' + } + }); - // Post a chat message to the app - post('http://localhost:' + server.address().port + '/echo', { - headers: { - 'X-OUTBOUND-TOKEN': - // Test an invalid body signature - 'invalidsignature' - }, - json: true, - body: { - type: 'message-created', - content: 'Hello there', - userName: 'Jane', - spaceId: 'testspace' - } - }, (err, val) => { - expect(err).to.equal(null); - - // Expect the request to be rejected - expect(val.statusCode).to.equal(401); - - check(); - }); - }); + // Expect an invalid status code + expect(val.statusCode).to.equal(401); + } + catch(err) { + throw err; + } }); + }); +