Mobrovac has uploaded a new change for review. ( 
https://gerrit.wikimedia.org/r/390989 )

Change subject: Update to service-template-node v0.5.3
......................................................................

Update to service-template-node v0.5.3

Bug: T151396
Change-Id: Ia35b7bd3d5d030e6f3fc4e0ac629636cda37c3a8
---
A .eslintrc.yml
M .jshintrc
M .travis.yml
M app.js
M lib/api-util.js
A lib/swagger-ui.js
M lib/util.js
M package.json
D routes/empty.js.template
M routes/info.js
M routes/mathoid.js
M routes/root.js
M server.js
M test/features/app/app.js
M test/features/app/spec.js
M test/features/info/info.js
M test/index.js
M test/utils/server.js
18 files changed, 573 insertions(+), 486 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/mediawiki/services/mathoid 
refs/changes/89/390989/1

diff --git a/.eslintrc.yml b/.eslintrc.yml
new file mode 100644
index 0000000..2cd5d61
--- /dev/null
+++ b/.eslintrc.yml
@@ -0,0 +1,6 @@
+extends: 'eslint-config-node-services'
+rules:
+  indent:
+    - error
+    - 4
+    - SwitchCase: 1
diff --git a/.jshintrc b/.jshintrc
index 9d3785f..afaeeec 100644
--- a/.jshintrc
+++ b/.jshintrc
@@ -1,12 +1,29 @@
 {
+    "predef": [
+        "ve",
+        "setImmediate",
+        "QUnit",
+        "Map",
+        "Set"
+    ],
     "bitwise": true,
+    "laxbreak": true,
+    "curly": true,
     "eqeqeq": true,
-    "freeze": true,
-    "latedef": "nofunc",
-    "futurehostile": true,
+    "immed": true,
+    "latedef": true,
+    "newcap": true,
     "noarg": true,
+    "noempty": true,
     "nonew": true,
+    "regexp": false,
     "undef": true,
+    "strict": true,
+    "trailing": true,
+    "smarttabs": true,
+    "multistr": true,
     "node": true,
-    "esversion": 6
+    "nomen": false,
+    "loopfunc": true,
+    "esnext": true
 }
diff --git a/.travis.yml b/.travis.yml
index 21bdcdf..c5971ac 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,6 +3,7 @@
 node_js:
   - "4"
   - "6"
+  - "8"
   - "node"
 
 script: npm run-script coverage && (npm run-script coveralls || exit 0)
diff --git a/app.js b/app.js
index 1bfcc89..7027a6d 100644
--- a/app.js
+++ b/app.js
@@ -6,19 +6,19 @@
 'use strict';
 
 
-require('core-js/shim');
+const http = require('http');
+const BBPromise = require('bluebird');
+const express = require('express');
+const compression = require('compression');
+const bodyParser = require('body-parser');
+const fs = BBPromise.promisifyAll(require('fs'));
+const sUtil = require('./lib/util');
+const apiUtil = require('./lib/api-util');
+const packageInfo = require('./package.json');
+const yaml = require('js-yaml');
+const addShutdown = require('http-shutdown');
 
-var http = require('http');
-var BBPromise = require('bluebird');
-var express = require('express');
-var compression = require('compression');
-var bodyParser = require('body-parser');
-var fs = BBPromise.promisifyAll(require('fs'));
-var sUtil = require('./lib/util');
-var apiUtil = require('./lib/api-util');
-var packageInfo = require('./package.json');
-var yaml = require('js-yaml');
-var mjAPI = require("mathoid-mathjax-node/lib/mj-single.js");
+const mjAPI = require("mathoid-mathjax-node/lib/mj-single.js");
 
 
 /**
@@ -29,7 +29,7 @@
 function initApp(options) {
 
     // the main application object
-    var app = express();
+    const app = express();
 
     // get the options and make them available in the app
     app.logger = options.logger;    // the logging device
@@ -38,22 +38,22 @@
     app.info = packageInfo;         // this app's package info
 
     // ensure some sane defaults
-    if(!app.conf.port) { app.conf.port = 10042; }
-    if(!app.conf.interface) { app.conf.interface = '0.0.0.0'; }
-    if(app.conf.compression_level === undefined) { app.conf.compression_level 
= 3; }
-    if(app.conf.cors === undefined) { app.conf.cors = '*'; }
-    if(app.conf.csp === undefined) {
-        app.conf.csp =
-            "default-src 'self'; object-src 'none'; media-src *; img-src *; 
style-src *; frame-ancestors 'self'";
+    if (!app.conf.port) { app.conf.port = 10042; }
+    if (!app.conf.interface) { app.conf.interface = '0.0.0.0'; }
+    if (app.conf.compression_level === undefined) { app.conf.compression_level 
= 3; }
+    if (app.conf.cors === undefined) { app.conf.cors = '*'; }
+    if (app.conf.csp === undefined) {
+        // eslint-disable-next-line max-len
+        app.conf.csp = "default-src 'self'; object-src 'none'; media-src *; 
img-src *; style-src *; frame-ancestors 'self'";
     }
 
     // set outgoing proxy
-    if(app.conf.proxy) {
+    if (app.conf.proxy) {
         process.env.HTTP_PROXY = app.conf.proxy;
         // if there is a list of domains which should
         // not be proxied, set it
-        if(app.conf.no_proxy_list) {
-            if(Array.isArray(app.conf.no_proxy_list)) {
+        if (app.conf.no_proxy_list) {
+            if (Array.isArray(app.conf.no_proxy_list)) {
                 process.env.NO_PROXY = app.conf.no_proxy_list.join(',');
             } else {
                 process.env.NO_PROXY = app.conf.no_proxy_list;
@@ -62,35 +62,35 @@
     }
 
     // set up header whitelisting for logging
-    if(!app.conf.log_header_whitelist) {
+    if (!app.conf.log_header_whitelist) {
         app.conf.log_header_whitelist = [
-                'cache-control', 'content-type', 'content-length', 'if-match',
-                'user-agent', 'x-request-id'
+            'cache-control', 'content-type', 'content-length', 'if-match',
+            'user-agent', 'x-request-id'
         ];
     }
-    app.conf.log_header_whitelist = new RegExp('^(?:' + 
app.conf.log_header_whitelist.map(function(item) {
+    app.conf.log_header_whitelist = new 
RegExp(`^(?:${app.conf.log_header_whitelist.map((item) => {
         return item.trim();
-    }).join('|') + ')$', 'i');
+    }).join('|')})$`, 'i');
 
     // set up the request templates for the APIs
     apiUtil.setupApiTemplates(app);
 
     // set up the spec
-    if(!app.conf.spec) {
-        app.conf.spec = __dirname + '/spec.yaml';
+    if (!app.conf.spec) {
+        app.conf.spec = `${__dirname}/spec.yaml`;
     }
-    if(app.conf.spec.constructor !== Object) {
+    if (app.conf.spec.constructor !== Object) {
         try {
             app.conf.spec = yaml.safeLoad(fs.readFileSync(app.conf.spec));
-        } catch(e) {
-            app.logger.log('warn/spec', 'Could not load the spec: ' + e);
+        } catch (e) {
+            app.logger.log('warn/spec', `Could not load the spec: ${e}`);
             app.conf.spec = {};
         }
     }
-    if(!app.conf.spec.swagger) {
+    if (!app.conf.spec.swagger) {
         app.conf.spec.swagger = '2.0';
     }
-    if(!app.conf.spec.info) {
+    if (!app.conf.spec.info) {
         app.conf.spec.info = {
             version: app.info.version,
             title: app.info.name,
@@ -98,18 +98,18 @@
         };
     }
     app.conf.spec.info.version = app.info.version;
-    if(!app.conf.spec.paths) {
+    if (!app.conf.spec.paths) {
         app.conf.spec.paths = {};
     }
 
     // set the CORS and CSP headers
-    app.all('*', function(req, res, next) {
-        if(app.conf.cors !== false) {
+    app.all('*', (req, res, next) => {
+        if (app.conf.cors !== false) {
             res.header('access-control-allow-origin', app.conf.cors);
             res.header('access-control-allow-headers', 'accept, 
x-requested-with, content-type');
             res.header('access-control-expose-headers', 'etag');
         }
-        if(app.conf.csp !== false) {
+        if (app.conf.csp !== false) {
             res.header('x-xss-protection', '1; mode=block');
             res.header('x-content-type-options', 'nosniff');
             res.header('x-frame-options', 'SAMEORIGIN');
@@ -129,11 +129,11 @@
     // disable the ETag header - users should provide them!
     app.set('etag', false);
     // enable compression
-    app.use(compression({level: app.conf.compression_level}));
+    app.use(compression({ level: app.conf.compression_level }));
     // use the JSON body parser
     app.use(bodyParser.json());
     // use the application/x-www-form-urlencoded parser
-    app.use(bodyParser.urlencoded({extended: true}));
+    app.use(bodyParser.urlencoded({ extended: true }));
 
     mjAPI.config({
         MathJax: {
@@ -157,43 +157,44 @@
  * @param {Application} app the application object to load routes into
  * @returns {bluebird} a promise resolving to the app object
  */
-function loadRoutes (app) {
+function loadRoutes(app) {
 
     // get the list of files in routes/
-    return fs.readdirAsync(__dirname + '/routes').map(function(fname) {
-        return BBPromise.try(function() {
+    return fs.readdirAsync(`${__dirname}/routes`).map((fname) => {
+        return BBPromise.try(() => {
             // ... and then load each route
             // but only if it's a js file
-            if(!/\.js$/.test(fname)) {
+            if (!/\.js$/.test(fname)) {
                 return undefined;
             }
             // import the route file
-            var route = require(__dirname + '/routes/' + fname);
+            const route = require(`${__dirname}/routes/${fname}`);
             return route(app);
-        }).then(function(route) {
-            if(route === undefined) {
+        }).then((route) => {
+            if (route === undefined) {
                 return undefined;
             }
             // check that the route exports the object we need
-            if(route.constructor !== Object || !route.path || !route.router || 
!(route.api_version || route.skip_domain)) {
-                throw new TypeError('routes/' + fname + ' does not export the 
correct object!');
+            if (route.constructor !== Object || !route.path || !route.router
+                || !(route.api_version || route.skip_domain)) {
+                throw new TypeError(`routes/${fname} does not export the 
correct object!`);
             }
             // normalise the path to be used as the mount point
-            if(route.path[0] !== '/') {
-                route.path = '/' + route.path;
+            if (route.path[0] !== '/') {
+                route.path = `/${route.path}`;
             }
-            if(route.path[route.path.length - 1] !== '/') {
-                route.path = route.path + '/';
+            if (route.path[route.path.length - 1] !== '/') {
+                route.path = `${route.path}/`;
             }
-            if(!route.skip_domain) {
-                route.path = '/:domain/v' + route.api_version + route.path;
+            if (!route.skip_domain) {
+                route.path = `/:domain/v${route.api_version}${route.path}`;
             }
             // wrap the route handlers with Promise.try() blocks
             sUtil.wrapRouteHandlers(route, app);
             // all good, use that route
             app.use(route.path, route.router);
         });
-    }).then(function () {
+    }).then(() => {
         // catch errors
         sUtil.setErrorHandler(app);
         // route loading is now complete, return the app object
@@ -213,16 +214,25 @@
     // return a promise which creates an HTTP server,
     // attaches the app to it, and starts accepting
     // incoming client requests
-    var server;
-    return new BBPromise(function (resolve) {
+    let server;
+    return new BBPromise((resolve) => {
         server = http.createServer(app).listen(
             app.conf.port,
             app.conf.interface,
             resolve
         );
-    }).then(function () {
+        server = addShutdown(server);
+    }).then(() => {
         app.logger.log('info',
-            'Worker ' + process.pid + ' listening on ' + (app.conf.interface 
|| '*') + ':' + app.conf.port);
+            `Worker ${process.pid} listening on ${app.conf.interface || 
'*'}:${app.conf.port}`);
+
+        // Don't delay incomplete packets for 40ms (Linux default) on
+        // pipelined HTTP sockets. We write in large chunks or buffers, so
+        // lack of coalescing should not be an issue here.
+        server.on("connection", (socket) => {
+            socket.setNoDelay(true);
+        });
+
         return server;
     });
 
@@ -239,9 +249,9 @@
 
     return initApp(options)
     .then(loadRoutes)
-    .then(function(app) {
+    .then((app) => {
         // serve static files from static/
-        app.use('/static', express.static(__dirname + '/static'));
+        app.use('/static', express.static(`${__dirname}/static`));
         return app;
     }).then(createServer);
 
diff --git a/lib/api-util.js b/lib/api-util.js
index 998f2bd..67f9690 100644
--- a/lib/api-util.js
+++ b/lib/api-util.js
@@ -1,36 +1,34 @@
 'use strict';
 
 
-var preq = require('preq');
-var sUtil = require('../lib/util');
-var Template = require('swagger-router').Template;
-
-var HTTPError = sUtil.HTTPError;
+const preq = require('preq');
+const sUtil = require('./util');
+const Template = require('swagger-router').Template;
+const HTTPError = sUtil.HTTPError;
 
 
 /**
  * Calls the MW API with the supplied query as its body
- *
- * @param {Object} app the application object
+ * @param {!Object} app the application object
  * @param {string} domain the domain to issue the request to
- * @param {Object} query an object with all the query parameters for the MW API
- * @return {Promise} a promise resolving as the response object from the MW API
+ * @param {?Object} query an object with all the query parameters for the MW 
API
+ * @return {!Promise} a promise resolving as the response object from the MW 
API
  */
 function mwApiGet(app, domain, query) {
 
     query = query || {};
     query.continue = query.continue || '';
 
-    var request = app.mwapi_tpl.expand({
+    const request = app.mwapi_tpl.expand({
         request: {
-            params: { domain: domain },
+            params: { domain },
             headers: { 'user-agent': app.conf.user_agent },
-            query: query
+            query
         }
     });
 
-    return preq(request).then(function(response) {
-        if(response.status < 200 || response.status > 399) {
+    return preq(request).then((response) => {
+        if (response.status < 200 || response.status > 399) {
             // there was an error when calling the upstream service, propagate 
that
             throw new HTTPError({
                 status: response.status,
@@ -47,27 +45,25 @@
 
 /**
  * Calls the REST API with the supplied domain, path and request parameters
- *
- * @param {Object} app the application object
+ * @param {!Object} app the application object
  * @param {string} domain the domain to issue the request for
- * @param {string} path the REST API path to contact without the leading slash
- * @param {Object} [restReq={}] the object containing the REST request details
- * @param {string} [restReq.method=get] the request method
- * @param {Object} [restReq.query={}] the query string to send, if any
- * @param {Object} [restReq.headers={}] the request headers to send
- * @param {Object} [restReq.body=null] the body of the request, if any
- * @return {Promise} a promise resolving as the response object from the REST 
API
- *
+ * @param {!string} path the REST API path to contact without the leading slash
+ * @param {?Object} [restReq={}] the object containing the REST request details
+ * @param {?string} [restReq.method=get] the request method
+ * @param {?Object} [restReq.query={}] the query string to send, if any
+ * @param {?Object} [restReq.headers={}] the request headers to send
+ * @param {?Object} [restReq.body=null] the body of the request, if any
+ * @return {!Promise} a promise resolving as the response object from the REST 
API
  */
 function restApiGet(app, domain, path, restReq) {
 
     restReq = restReq || {};
     path = path[0] === '/' ? path.slice(1) : path;
 
-    var request = app.restbase_tpl.expand({
+    const request = app.restbase_tpl.expand({
         request: {
             method: restReq.method,
-            params: { domain: domain, path: path },
+            params: { domain, path },
             query: restReq.query,
             headers: Object.assign({ 'user-agent': app.conf.user_agent }, 
restReq.headers),
             body: restReq.body
@@ -81,13 +77,12 @@
 
 /**
  * Sets up the request templates for MW and RESTBase API requests
- *
- * @param {Application} app the application object
+ * @param {!Application} app the application object
  */
 function setupApiTemplates(app) {
 
     // set up the MW API request template
-    if(!app.conf.mwapi_req) {
+    if (!app.conf.mwapi_req) {
         app.conf.mwapi_req = {
             uri: 'http://{{domain}}/w/api.php',
             headers: {
@@ -99,7 +94,7 @@
     app.mwapi_tpl = new Template(app.conf.mwapi_req);
 
     // set up the RESTBase request template
-    if(!app.conf.restbase_req) {
+    if (!app.conf.restbase_req) {
         app.conf.restbase_req = {
             method: '{{request.method}}',
             uri: 'http://{{domain}}/api/rest_v1/{+path}',
@@ -114,8 +109,8 @@
 
 
 module.exports = {
-    mwApiGet: mwApiGet,
-    restApiGet: restApiGet,
-    setupApiTemplates: setupApiTemplates
+    mwApiGet,
+    restApiGet,
+    setupApiTemplates
 };
 
diff --git a/lib/swagger-ui.js b/lib/swagger-ui.js
new file mode 100644
index 0000000..9e39ff5
--- /dev/null
+++ b/lib/swagger-ui.js
@@ -0,0 +1,79 @@
+'use strict';
+
+
+const BBPromise = require('bluebird');
+const fs = BBPromise.promisifyAll(require('fs'));
+const path = require('path');
+const HTTPError = require('../lib/util.js').HTTPError;
+
+
+// Swagger-ui helpfully exporting the absolute path of its dist directory
+const docRoot = `${require('swagger-ui').dist}/`;
+
+function processRequest(app, req, res) {
+
+    const reqPath = req.query.path || '/index.html';
+    const filePath = path.join(docRoot, reqPath);
+
+    // Disallow relative paths.
+    // Test relies on docRoot ending on a slash.
+    if (filePath.substring(0, docRoot.length) !== docRoot) {
+        throw new HTTPError({
+            status: 404,
+            type: 'not_found',
+            title: 'File not found',
+            detail: `${reqPath} could not be found.`
+        });
+    }
+
+    return fs.readFileAsync(filePath)
+    .then((body) => {
+        if (reqPath === '/index.html') {
+            body = body.toString()
+                .replace(/((?:src|href)=['"])/g, '$1?doc&path=')
+                // Some self-promotion
+                .replace(/<a id="logo".*?<\/a>/,
+                        `<a id="logo" 
href="${app.info.homepage}">${app.info.name}</a>`)
+                .replace(/<title>[^<]*<\/title>/, 
`<title>${app.info.name}</title>`)
+                // Replace the default url with ours, switch off validation &
+                // limit the size of documents to apply syntax highlighting to
+                .replace(/docExpansion: "none"/, 'docExpansion: "list", '
+                    + 'validatorUrl: null, '
+                    + 'highlightSizeThreshold: 10000')
+                .replace(/ url: url,/, 'url: "/?spec",');
+        }
+
+        let contentType = 'text/html';
+        if (/\.js$/.test(reqPath)) {
+            contentType = 'text/javascript';
+            body = body.toString()
+                .replace(/underscore-min\.map/, 
'?doc&path=lib/underscore-min.map');
+        } else if (/\.png$/.test(reqPath)) {
+            contentType = 'image/png';
+        } else if (/\.map$/.test(reqPath)) {
+            contentType = 'application/json';
+        } else if (/\.ttf$/.test(reqPath)) {
+            contentType = 'application/x-font-ttf';
+        } else if (/\.css$/.test(reqPath)) {
+            contentType = 'text/css';
+            body = body.toString().replace(/\.\.\/(images|fonts)\//g, 
'?doc&path=$1/');
+        }
+
+        res.setHeader('Content-Type', contentType);
+        res.setHeader('content-security-policy', "default-src 'none'; " +
+                        "script-src 'self' 'unsafe-inline'; connect-src *; " +
+                        "style-src 'self' 'unsafe-inline'; img-src 'self'; 
font-src 'self';");
+        res.send(body.toString());
+    })
+    .catch({ code: 'ENOENT' }, () => {
+        res.status(404)
+            .type('not_found')
+            .send('not found');
+    });
+
+}
+
+module.exports = {
+    processRequest
+};
+
diff --git a/lib/util.js b/lib/util.js
index 9c80ca8..44ee389 100644
--- a/lib/util.js
+++ b/lib/util.js
@@ -1,57 +1,50 @@
 'use strict';
 
 
-var BBPromise = require('bluebird');
-var util = require('util');
-var express = require('express');
-var uuid = require('cassandra-uuid');
-var bunyan = require('bunyan');
+const BBPromise = require('bluebird');
+const express = require('express');
+const uuid = require('cassandra-uuid');
+const bunyan = require('bunyan');
 
 
 /**
  * Error instance wrapping HTTP error responses
  */
-function HTTPError(response) {
+class HTTPError extends Error {
 
-    Error.call(this);
-    Error.captureStackTrace(this, HTTPError);
+    constructor(response) {
+        super();
+        Error.captureStackTrace(this, HTTPError);
 
-    if(response.constructor !== Object) {
-        // just assume this is just the error message
-        var msg = response;
-        response = {
-            status: 500,
-            type: 'internal_error',
-            title: 'InternalError',
-            detail: msg
-        };
+        if (response.constructor !== Object) {
+            // just assume this is just the error message
+            response = {
+                status: 500,
+                type: 'internal_error',
+                title: 'InternalError',
+                detail: response
+            };
+        }
+
+        this.name = this.constructor.name;
+        this.message = `${response.status}`;
+        if (response.type) {
+            this.message += `: ${response.type}`;
+        }
+
+        Object.assign(this, response);
     }
-
-    this.name = this.constructor.name;
-    this.message = response.status + '';
-    if(response.type) {
-        this.message += ': ' + response.type;
-    }
-
-    for (var key in response) {
-        this[key] = response[key];
-    }
-
 }
-
-util.inherits(HTTPError, Error);
-
 
 /**
  * Generates an object suitable for logging out of a request object
- *
- * @param {Request} req          the request
- * @param {RegExp}  whitelistRE  the RegExp used to filter headers
- * @return {Object} an object containing the key components of the request
+ * @param {!Request} req          the request
+ * @param {?RegExp}  whitelistRE  the RegExp used to filter headers
+ * @return {!Object} an object containing the key components of the request
  */
 function reqForLog(req, whitelistRE) {
 
-    var ret = {
+    const ret = {
         url: req.originalUrl,
         headers: {},
         method: req.method,
@@ -62,9 +55,9 @@
         remotePort: req.connection.remotePort
     };
 
-    if(req.headers && whitelistRE) {
-        Object.keys(req.headers).forEach(function(hdr) {
-            if(whitelistRE.test(hdr)) {
+    if (req.headers && whitelistRE) {
+        Object.keys(req.headers).forEach((hdr) => {
+            if (whitelistRE.test(hdr)) {
                 ret.headers[hdr] = req.headers[hdr];
             }
         });
@@ -76,20 +69,19 @@
 
 
 /**
- * Serialises an error object in a form suitable for logging
- *
- * @param {Error} err error to serialise
- * @return {Object} the serialised version of the error
+ * Serialises an error object in a form suitable for logging.
+ * @param {!Error} err error to serialise
+ * @return {!Object} the serialised version of the error
  */
 function errForLog(err) {
 
-    var ret = bunyan.stdSerializers.err(err);
+    const ret = bunyan.stdSerializers.err(err);
     ret.status = err.status;
     ret.type = err.type;
     ret.detail = err.detail;
 
     // log the stack trace only for 500 errors
-    if(Number.parseInt(ret.status) !== 500) {
+    if (Number.parseInt(ret.status, 10) !== 500) {
         ret.stack = undefined;
     }
 
@@ -98,9 +90,8 @@
 }
 
 /**
- * Generates a unique request ID
- *
- * @return {String} the generated request ID
+ * Generates a unique request ID.
+ * @return {!String} the generated request ID
  */
 function generateRequestId() {
 
@@ -114,37 +105,34 @@
  * promised try blocks so as to allow catching all errors,
  * regardless of whether a handler returns/uses promises
  * or not.
- *
- * @param {Object} route the object containing the router and path to bind it 
to
- * @param {Application} app the application object
+ * @param {!Object} route the object containing the router and path to bind it 
to
+ * @param {!Application} app the application object
  */
 function wrapRouteHandlers(route, app) {
 
-    route.router.stack.forEach(function(routerLayer) {
-        var path = (route.path + routerLayer.route.path.slice(1))
+    route.router.stack.forEach((routerLayer) => {
+        let path = (route.path + routerLayer.route.path.slice(1))
             .replace(/\/:/g, '/--')
             .replace(/^\//, '')
-            .replace(/[\/?]+$/, '');
+            .replace(/[/?]+$/, '');
         path = app.metrics.normalizeName(path || 'root');
-        routerLayer.route.stack.forEach(function(layer) {
-            var origHandler = layer.handle;
+        routerLayer.route.stack.forEach((layer) => {
+            const origHandler = layer.handle;
             layer.handle = function(req, res, next) {
-                var startTime = Date.now();
-                BBPromise.try(function() {
-                    return origHandler(req, res, next);
-                })
+                const startTime = Date.now();
+                BBPromise.try(() => origHandler(req, res, next))
                 .catch(next)
-                .finally(function() {
-                    var statusCode = parseInt(res.statusCode) || 500;
-                    if(statusCode < 100 || statusCode > 599) {
+                .finally(() => {
+                    let statusCode = parseInt(res.statusCode, 10) || 500;
+                    if (statusCode < 100 || statusCode > 599) {
                         statusCode = 500;
                     }
-                    var statusClass = Math.floor(statusCode / 100) + 'xx';
-                    var stat = path + '.' + req.method + '.';
+                    const statusClass = `${Math.floor(statusCode / 100)}xx`;
+                    const stat = `${path}.${req.method}.`;
                     app.metrics.endTiming([
                         stat + statusCode,
                         stat + statusClass,
-                        stat + 'ALL'
+                        `${stat}ALL`
                     ], startTime);
                 });
             };
@@ -155,24 +143,22 @@
 
 
 /**
- * Generates an error handler for the given applications
- * and installs it. Usage:
- *
- * @param {Application} app the application object to add the handler to
+ * Generates an error handler for the given applications and installs it.
+ * @param {!Application} app the application object to add the handler to
  */
 function setErrorHandler(app) {
 
-    app.use(function(err, req, res, next) {
-        var errObj;
+    app.use((err, req, res, next) => {
+        let errObj;
         // ensure this is an HTTPError object
-        if(err.constructor === HTTPError) {
+        if (err.constructor === HTTPError) {
             errObj = err;
-        } else if(err instanceof Error) {
+        } else if (err instanceof Error) {
             // is this an HTTPError defined elsewhere? (preq)
-            if(err.constructor.name === 'HTTPError') {
-                var o = { status: err.status };
-                if(err.body && err.body.constructor === Object) {
-                    Object.keys(err.body).forEach(function(key) {
+            if (err.constructor.name === 'HTTPError') {
+                const o = { status: err.status };
+                if (err.body && err.body.constructor === Object) {
+                    Object.keys(err.body).forEach((key) => {
                         o[key] = err.body[key];
                     });
                 } else {
@@ -190,7 +176,7 @@
                     stack: err.stack
                 });
             }
-        } else if(err.constructor === Object) {
+        } else if (err.constructor === Object) {
             // this is a regular object, suppose it's a response
             errObj = new HTTPError(err);
         } else {
@@ -203,33 +189,30 @@
             });
         }
         // ensure some important error fields are present
-        if(!errObj.status) { errObj.status = 500; }
-        if(!errObj.type) { errObj.type = 'internal_error'; }
+        if (!errObj.status) { errObj.status = 500; }
+        if (!errObj.type) { errObj.type = 'internal_error'; }
         // add the offending URI and method as well
-        if(!errObj.method) { errObj.method = req.method; }
-        if(!errObj.uri) { errObj.uri = req.url; }
-        // Keep error compatible to mathoid 0.2.x API
-        if(!errObj.success) { errObj.success = false; }
+        if (!errObj.method) { errObj.method = req.method; }
+        if (!errObj.uri) { errObj.uri = req.url; }
         // some set 'message' or 'description' instead of 'detail'
         errObj.detail = errObj.detail || errObj.message || errObj.description 
|| '';
         // Keep error compatible to mathoid 0.2.x API
-        if(!errObj.log) { errObj.log = errObj.detail; }
-        // Keep error compatible to mathoid 0.2.x API
-        if(!errObj.error) { errObj.error = ''; }
+        if (!errObj.log) { errObj.log = errObj.detail; }
+        if (!errObj.error) { errObj.error = ''; }
+        if (!errObj.success) { errObj.success = false; }
         //
         // adjust the log level based on the status code
-        var level = 'error';
-        if(Number.parseInt(errObj.status) < 400) {
+        let level = 'error';
+        if (Number.parseInt(errObj.status, 10) < 400) {
             level = 'trace';
-        } else if(Number.parseInt(errObj.status) < 500) {
+        } else if (Number.parseInt(errObj.status, 10) < 500) {
             level = 'info';
         }
         // log the error
-        (req.logger || app.logger).log(level + '/' +
-                (errObj.component ? errObj.component : errObj.status),
-                errForLog(errObj));
+        const component = (errObj.component ? errObj.component : 
errObj.status);
+        (req.logger || app.logger).log(`${level}/${component}`, 
errForLog(errObj));
         // let through only non-sensitive info
-        var respBody = {
+        const respBody = {
             status: errObj.status,
             type: errObj.type,
             title: errObj.title,
@@ -248,46 +231,45 @@
 
 /**
  * Creates a new router with some default options.
- *
- * @param {Object} [opts] additional options to pass to express.Router()
- * @return {Router} a new router object
+ * @param {?Object} [opts] additional options to pass to express.Router()
+ * @return {!Router} a new router object
  */
 function createRouter(opts) {
 
-    var options = {
+    const options = {
         mergeParams: true
     };
 
-    if(opts && opts.constructor === Object) {
-        Object.keys(opts).forEach(function(key) {
-            options[key] = opts[key];
-        });
+    if (opts && opts.constructor === Object) {
+        Object.assign(options, opts);
     }
 
-    return express.Router(options);
+    return new express.Router(options);
 
 }
 
 
 /**
- * Adds logger to the request and logs it
- *
- * @param {*} req request object
- * @param {Application} app application object
+ * Adds logger to the request and logs it.
+ * @param {!*} req request object
+ * @param {!Application} app application object
  */
 function initAndLogRequest(req, app) {
     req.headers = req.headers || {};
     req.headers['x-request-id'] = req.headers['x-request-id'] || 
generateRequestId();
-    req.logger = app.logger.child({request_id: req.headers['x-request-id'], 
request: reqForLog(req, app.conf.log_header_whitelist)});
-    req.logger.log('trace/req', {msg: 'incoming request'});
+    req.logger = app.logger.child({
+        request_id: req.headers['x-request-id'],
+        request: reqForLog(req, app.conf.log_header_whitelist)
+    });
+    req.logger.log('trace/req', { msg: 'incoming request' });
 }
 
 
 module.exports = {
-    HTTPError: HTTPError,
-    initAndLogRequest: initAndLogRequest,
-    wrapRouteHandlers: wrapRouteHandlers,
-    setErrorHandler: setErrorHandler,
+    HTTPError,
+    initAndLogRequest,
+    wrapRouteHandlers,
+    setErrorHandler,
     router: createRouter
 };
 
diff --git a/package.json b/package.json
index 5852b21..d003fe6 100644
--- a/package.json
+++ b/package.json
@@ -5,9 +5,10 @@
   "main": "./app.js",
   "scripts": {
     "start": "service-runner",
-    "test": "mocha && nsp check",
+    "test": "PREQ_CONNECT_TIMEOUT=15 mocha && nsp check",
     "docker-start": "service-runner docker-start",
     "docker-test": "service-runner docker-test",
+    "test-build": "service-runner docker-test && service-runner build 
--deploy-repo --force",
     "coverage": "istanbul cover _mocha -- -R spec",
     "coveralls": "cat ./coverage/lcov.info | coveralls"
   },
@@ -36,30 +37,39 @@
   },
   "homepage": "https://github.com/wikimedia/mathoid";,
   "dependencies": {
-    "bluebird": "^3.4.1",
-    "body-parser": "^1.15.2",
-    "bunyan": "^1.8.1",
+    "bluebird": "^3.5.1",
+    "body-parser": "^1.18.2",
+    "bunyan": "^1.8.12",
     "cassandra-uuid": "^0.0.2",
-    "compression": "^1.6.2",
+    "compression": "^1.7.1",
     "core-js": "^2.4.1",
-    "express": "^4.14.0",
-    "js-yaml": "^3.6.1",
+    "express": "^4.16.2",
+    "http-shutdown": "^1.2.0",
+    "js-yaml": "^3.10.0",
     "mathoid-mathjax-node": "^0.6.6",
-    "preq": "^0.4.10",
-    "service-runner": "^2.2.5",
+    "mathoid-texvcjs": "0.3.2",
+    "preq": "^0.5.3",
+    "service-runner": "^2.4.2",
     "svgo": "^0.7.1",
-    "texvcinfo": "^0.4.2",
-    "swagger-router": "^0.5.2"
+    "swagger-router": "^0.7.1",
+    "swagger-ui": "git+https://github.com/wikimedia/swagger-ui#master";,
+    "texvcinfo": "^0.4.2"
   },
   "devDependencies": {
     "commander": "^2.9.0",
     "dom-compare": "^0.2.1",
     "extend": "^3.0.0",
-    "istanbul": "^0.4.4",
-    "mocha": "^3.1.2",
+    "istanbul": "^0.4.5",
+    "mocha": "^4.0.1",
     "mocha-jshint": "^2.3.1",
-    "mocha-lcov-reporter": "^1.2.0",
-    "nsp": "^2.6.1",
+    "mocha-lcov-reporter": "^1.3.0",
+    "mocha-eslint": "^3.0.1",
+    "eslint": "^3.12.0",
+    "eslint-config-node-services": "^2.0.2",
+    "eslint-config-wikimedia": "^0.4.0",
+    "eslint-plugin-json": "^1.2.0",
+    "eslint-plugin-jsdoc": "^3.0.0",
+    "nsp": "^2.8.1",
     "rewire": "^2.5.1",
     "xmldom": "^0.1.21"
   },
diff --git a/routes/empty.js.template b/routes/empty.js.template
deleted file mode 100644
index 97cff86..0000000
--- a/routes/empty.js.template
+++ /dev/null
@@ -1,39 +0,0 @@
-'use strict';
-
-
-var BBPromise = require('bluebird');
-var preq = require('preq');
-var sUtil = require('../lib/util');
-
-// shortcut
-var HTTPError = sUtil.HTTPError;
-
-
-/**
- * The main router object
- */
-var router = sUtil.router();
-
-/**
- * The main application object reported when this module is require()d
- */
-var app;
-
-
-/** ROUTE DECLARATIONS GO HERE **/
-
-
-module.exports = function(appObj) {
-
-    app = appObj;
-
-    // the returned object mounts the routes on
-    // /{domain}/vX/mount/path
-    return {
-        path: '/mount/path',
-        api_version: X,  // must be a number!
-        router: router
-    };
-
-};
-
diff --git a/routes/info.js b/routes/info.js
index 5dd2590..832af0d 100644
--- a/routes/info.js
+++ b/routes/info.js
@@ -1,25 +1,25 @@
 'use strict';
 
 
-var sUtil = require('../lib/util');
+const sUtil = require('../lib/util');
 
 
 /**
  * The main router object
  */
-var router = sUtil.router();
+const router = sUtil.router();
 
 /**
  * The main application object reported when this module is require()d
  */
-var app;
+let app;
 
 
 /**
  * GET /
  * Gets some basic info about this service
  */
-router.get('/', function(req, res) {
+router.get('/', (req, res) => {
 
     // simple sync return
     res.json({
@@ -36,7 +36,7 @@
  * GET /name
  * Gets the service's name as defined in package.json
  */
-router.get('/name', function(req, res) {
+router.get('/name', (req, res) => {
 
     // simple return
     res.json({ name: app.info.name });
@@ -48,7 +48,7 @@
  * GET /version
  * Gets the service's version as defined in package.json
  */
-router.get('/version', function(req, res) {
+router.get('/version', (req, res) => {
 
     // simple return
     res.json({ version: app.info.version });
@@ -61,29 +61,28 @@
  * Redirects to the service's home page if one is given,
  * returns a 404 otherwise
  */
-router.all('/home', function(req, res) {
+router.all('/home', (req, res) => {
 
-    var home = app.info.homepage;
-    if(home && /^http/.test(home)) {
+    const home = app.info.homepage;
+    if (home && /^http/.test(home)) {
         // we have a home page URI defined, so send it
         res.redirect(301, home);
-        return;
     } else {
         // no URI defined for the home page, error out
-        res.status(404).end('No home page URL defined for ' + app.info.name);
+        res.status(404).end(`No home page URL defined for ${app.info.name}`);
     }
 
 });
 
 
-module.exports = function(appObj) {
+module.exports = (appObj) => {
 
     app = appObj;
 
     return {
         path: '/_info',
         skip_domain: true,
-        router: router
+        router
     };
 
 };
diff --git a/routes/mathoid.js b/routes/mathoid.js
index 433c9ce..743f122 100644
--- a/routes/mathoid.js
+++ b/routes/mathoid.js
@@ -1,15 +1,15 @@
 'use strict';
 
 
-var BBPromise = require('bluebird');
-var sUtil = require('../lib/util');
-var texvcInfo = require('texvcinfo');
-var SVGO = require('svgo');
+const BBPromise = require('bluebird');
+const sUtil = require('../lib/util');
+const texvcInfo = require('texvcinfo');
+const SVGO = require('svgo');
 
-var HTTPError = sUtil.HTTPError;
-var svgo = new SVGO({
+const HTTPError = sUtil.HTTPError;
+const svgo = new SVGO({
     plugins: [
-        {convertTransform: false}
+        { convertTransform: false }
     ]
 });
 
@@ -17,16 +17,16 @@
 /**
  * The main router object
  */
-var router = sUtil.router();
+const router = sUtil.router();
 
 /**
  * The main application object reported when this module is require()d
  */
-var app;
+let app;
 
 
 /* The response headers for different render types */
-var outHeaders = function (data) {
+function outHeaders(data) {
     return {
         svg: {
             'content-type': 'image/svg+xml'
@@ -39,7 +39,7 @@
             'x-mathoid-style': data.mathoidStyle
         }
     };
-};
+}
 
 
 function emitError(txt, detail) {
@@ -51,19 +51,19 @@
         success: false,
         title: 'Bad Request',
         type: 'bad_request',
-        detail: detail,
+        detail,
         error: txt
     });
 }
 
 function emitFormatError(format) {
-    emitError("Output format " + format + " is disabled via config, try 
setting \"" +
-        format + ": true\" to enable " + format + "rendering.");
+    emitError(`Output format ${format} is disabled via config, try setting ` +
+        `"${format}: true" to enable ${format} rendering.`);
 }
 
-var optimizeSvg = function (data, req, cb) {
+function optimizeSvg(data, req, cb) {
     try {
-        svgo.optimize(data.svg, function (result) {
+        svgo.optimize(data.svg, (result) => {
             if (!result.error) {
                 data.svg = result.data;
             } else {
@@ -75,33 +75,34 @@
         req.logger.log('warn/svgo', e);
         cb();
     }
-};
+}
 
 function handleRequest(res, q, type, outFormat, features, req) {
-    var sanitizedTex, feedback;
-    var svg = app.conf.svg && /^svg|json|complete$/.test(outFormat);
-    var mml = (type !== "MathML") && /^mml|json|complete$/.test(outFormat);
-    var png = app.conf.png && /^png|json|complete$/.test(outFormat);
-    var info = app.conf.texvcinfo && /^graph|texvcinfo$/.test(outFormat);
-    var img = app.conf.img && /^mml|json|complete$/.test(outFormat);
-    var speech = (outFormat !== "png") && features.speech || outFormat === 
"speech";
-    var chem = type === "chem";
+    let sanitizedTex;
+    let feedback;
+    const svg = app.conf.svg && /^svg|json|complete$/.test(outFormat);
+    const mml = (type !== "MathML") && /^mml|json|complete$/.test(outFormat);
+    const png = app.conf.png && /^png|json|complete$/.test(outFormat);
+    const info = app.conf.texvcinfo && /^graph|texvcinfo$/.test(outFormat);
+    const img = app.conf.img && /^mml|json|complete$/.test(outFormat);
+    const speech = (outFormat !== "png") && features.speech || outFormat === 
"speech";
+    const chem = type === "chem";
 
     if (chem) {
         type = "inline-TeX";
     }
     if ((!app.conf.no_check && /^TeX|inline-TeX$/.test(type)) || info) {
-        feedback = texvcInfo.feedback(q, {usemhchem: chem});
+        feedback = texvcInfo.feedback(q, { usemhchem: chem });
         // XXX properly handle errors here!
         if (feedback.success) {
             sanitizedTex = feedback.checked || '';
             q = sanitizedTex;
         } else {
-            emitError(feedback.error.name + ': ' + feedback.error.message, 
feedback);
+            emitError(`${feedback.error.name}: ${feedback.error.message}`, 
feedback);
         }
         if (info) {
             if (outFormat === "graph") {
-                res.json(texvcInfo.texvcinfo(q, {"format": "json", "compact": 
true}));
+                res.json(texvcInfo.texvcinfo(q, { format: "json", compact: 
true }));
                 return;
             }
             if (info && outFormat === "texvcinfo") {
@@ -111,23 +112,21 @@
         }
     }
 
-    var mathJaxOptions = {
+    const mathJaxOptions = {
         math: q,
         format: type,
-        svg: svg,
+        svg,
         mathoidStyle: img,
-        mml: mml,
+        mml,
         speakText: speech,
-        png: png
+        png
     };
     if (app.conf.dpi) {
         mathJaxOptions.dpi = app.conf.dpi;
     }
-    return new BBPromise(function(resolve, reject) {
-        app.mjAPI.typeset(mathJaxOptions, function (data) {
-            resolve(data);
-        });
-    }).then(function (data) {
+    return new BBPromise((resolve, reject) => {
+        app.mjAPI.typeset(mathJaxOptions, data => resolve(data));
+    }).then((data) => {
         if (data.errors) {
             emitError(data.errors);
         }
@@ -148,9 +147,9 @@
                 case 'json':
                     res.json(data).end();
                     break;
-                case 'complete':
-                    var headers = outHeaders(data);
-                    Object.keys(headers).forEach(function (outType) {
+                case 'complete': {
+                    const headers = outHeaders(data);
+                    Object.keys(headers).forEach((outType) => {
                         if (data[outType]) {
                             data[outType] = {
                                 headers: headers[outType],
@@ -160,6 +159,7 @@
                     });
                     res.json(data).end();
                     break;
+                }
                 default:
                     res.set(outHeaders(data)[outFormat]);
                     res.send(data[outFormat]).end();
@@ -179,15 +179,15 @@
  * POST /
  * Performs the rendering request
  */
-router.post('/:outformat?/', function (req, res) {
-    var outFormat;
-    var speech = app.conf.speech_on;
+router.post('/:outformat?/', (req, res) => {
+    let outFormat;
+    let speech = app.conf.speech_on;
     // First some rudimentary input validation
     if (!(req.body.q)) {
         emitError("q (query) post parameter is missing!");
     }
-    var q = req.body.q;
-    var type = (req.body.type || 'tex').toLowerCase();
+    const q = req.body.q;
+    let type = (req.body.type || 'tex').toLowerCase();
     switch (type) {
         case "tex":
             type = "TeX";
@@ -208,7 +208,7 @@
             type = "chem";
             break;
         default :
-            emitError("Input format \"" + type + "\" is not recognized!");
+            emitError(`Input format "${type}" is not recognized!`);
     }
     if (req.body.nospeech) {
         speech = false;
@@ -232,13 +232,15 @@
             case "texvcinfo":
                 setOutFormat('texvcinfo');
                 if (!/(chem|tex$)/i.test(type)) {
-                    emitError('texvcinfo accepts only tex, inline-tex, or chem 
as the input type, "' + type + '" given!');
+                    emitError('texvcinfo accepts only tex, inline-tex, or chem 
as the input type' +
+                        `, ${type} given!`);
                 }
                 break;
             case "graph":
                 setOutFormat('graph');
                 if (!/tex$/i.test(type)) {
-                    emitError('graph accepts only tex or inline-tex as the 
input type, "' + type + '" given!');
+                    emitError('graph accepts only tex or inline-tex as the 
input type, ' +
+                        `${type} given!`);
                 }
                 break;
             case "json":
@@ -255,24 +257,24 @@
                 setOutFormat('speech');
                 break;
             default:
-                emitError("Output format \"" + req.params.outformat + "\" is 
not recognized!");
+                emitError(`Output format "${req.params.outformat}" is not 
recognized!`);
         }
     } else {
         outFormat = "json";
     }
-    return handleRequest(res, q, type, outFormat, {speech: speech}, req);
+    return handleRequest(res, q, type, outFormat, { speech }, req);
 
 });
 
 
-module.exports = function (appObj) {
+module.exports = (appObj) => {
 
     app = appObj;
 
     return {
         path: '/',
         skip_domain: true,
-        router: router
+        router
     };
 
 };
diff --git a/routes/root.js b/routes/root.js
index 3b34d8e..83ee521 100644
--- a/routes/root.js
+++ b/routes/root.js
@@ -1,25 +1,26 @@
 'use strict';
 
 
-var sUtil = require('../lib/util');
+const sUtil = require('../lib/util');
+const swaggerUi = require('../lib/swagger-ui');
 
 
 /**
  * The main router object
  */
-var router = sUtil.router();
+const router = sUtil.router();
 
 /**
  * The main application object reported when this module is require()d
  */
-var app;
+let app;
 
 
 /**
  * GET /robots.txt
  * Instructs robots no indexing should occur on this domain.
  */
-router.get('/robots.txt', function(req, res) {
+router.get('/robots.txt', (req, res) => {
 
     res.set({
         'User-agent': '*',
@@ -31,28 +32,30 @@
 
 /**
  * GET /
- * Main entry point. Currently it only responds if the spec query
+ * Main entry point. Currently it only responds if the spec or doc query
  * parameter is given, otherwise lets the next middleware handle it
  */
-router.get('/', function(req, res, next) {
+router.get('/', (req, res, next) => {
 
-    if(!(req.query || {}).hasOwnProperty('spec')) {
-        next();
-    } else {
+    if ({}.hasOwnProperty.call(req.query || {}, 'spec')) {
         res.json(app.conf.spec);
+    } else if ({}.hasOwnProperty.call(req.query || {}, 'doc')) {
+        return swaggerUi.processRequest(app, req, res);
+    } else {
+        next();
     }
 
 });
 
 
-module.exports = function(appObj) {
+module.exports = (appObj) => {
 
     app = appObj;
 
     return {
         path: '/',
         skip_domain: true,
-        router: router
+        router
     };
 
 };
diff --git a/server.js b/server.js
index 185a13d..fe46326 100755
--- a/server.js
+++ b/server.js
@@ -8,5 +8,5 @@
 // (config.yaml by default, specify other path with -c). It requires the
 // module(s) specified in the config 'services' section (mathoid.js in this
 // example).
-var ServiceRunner = require('service-runner');
+const ServiceRunner = require('service-runner');
 new ServiceRunner().start();
diff --git a/test/features/app/app.js b/test/features/app/app.js
index ca6174e..3cdec05 100644
--- a/test/features/app/app.js
+++ b/test/features/app/app.js
@@ -1,33 +1,37 @@
 'use strict';
 
 
-var preq   = require('preq');
-var assert = require('../../utils/assert.js');
-var server = require('../../utils/server.js');
+const preq   = require('preq');
+const assert = require('../../utils/assert.js');
+const server = require('../../utils/server.js');
 
+if (!server.stopHookAdded) {
+    server.stopHookAdded = true;
+    after(() => server.stop());
+}
 
 describe('express app', function() {
 
-    this.timeout(20000);
+    this.timeout(20000); // eslint-disable-line no-invalid-this
 
-    before(function () { return server.start(); });
+    before(() => { return server.start(); });
 
-    it('should get robots.txt', function() {
+    it('should get robots.txt', () => {
         return preq.get({
-            uri: server.config.uri + 'robots.txt'
-        }).then(function(res) {
+            uri: `${server.config.uri}robots.txt`
+        }).then((res) => {
             assert.deepEqual(res.status, 200);
-            assert.deepEqual(res.headers['disallow'], '/');
+            assert.deepEqual(res.headers.disallow, '/');
         });
     });
 
-    it('should set CORS headers', function() {
-        if(server.config.service.conf.cors === false) {
+    it('should set CORS headers', () => {
+        if (server.config.service.conf.cors === false) {
             return true;
         }
         return preq.get({
-            uri: server.config.uri + 'robots.txt'
-        }).then(function(res) {
+            uri: `${server.config.uri}robots.txt`
+        }).then((res) => {
             assert.deepEqual(res.status, 200);
             assert.deepEqual(res.headers['access-control-allow-origin'], '*');
             assert.deepEqual(!!res.headers['access-control-allow-headers'], 
true);
@@ -35,13 +39,13 @@
         });
     });
 
-    it('should set CSP headers', function() {
-        if(server.config.service.conf.csp === false) {
+    it('should set CSP headers', () => {
+        if (server.config.service.conf.csp === false) {
             return true;
         }
         return preq.get({
-            uri: server.config.uri + 'robots.txt'
-        }).then(function(res) {
+            uri: `${server.config.uri}robots.txt`
+        }).then((res) => {
             assert.deepEqual(res.status, 200);
             assert.deepEqual(res.headers['x-xss-protection'], '1; mode=block');
             assert.deepEqual(res.headers['x-content-type-options'], 'nosniff');
diff --git a/test/features/app/spec.js b/test/features/app/spec.js
index 570fb40..8e9846a 100644
--- a/test/features/app/spec.js
+++ b/test/features/app/spec.js
@@ -1,25 +1,29 @@
 'use strict';
 
 
-var preq   = require('preq');
-var assert = require('../../utils/assert.js');
-var server = require('../../utils/server.js');
-var URI    = require('swagger-router').URI;
-var yaml   = require('js-yaml');
-var fs     = require('fs');
+const preq   = require('preq');
+const assert = require('../../utils/assert.js');
+const server = require('../../utils/server.js');
+const URI    = require('swagger-router').URI;
+const yaml   = require('js-yaml');
+const fs     = require('fs');
 
+if (!server.stopHookAdded) {
+    server.stopHookAdded = true;
+    after(() => server.stop());
+}
 
 function staticSpecLoad() {
 
-    var spec;
-    var myService = 
server.config.conf.services[server.config.conf.services.length - 1].conf;
-    var specPath = __dirname + '/../../../' + (myService.spec ? myService.spec 
: 'spec.yaml');
+    let spec;
+    const myService = 
server.config.conf.services[server.config.conf.services.length - 1].conf;
+    const specPath = `${__dirname}/../../../${myService.spec ? myService.spec 
: 'spec.yaml'}`;
 
     try {
         spec = yaml.safeLoad(fs.readFileSync(specPath));
-    } catch(e) {
+    } catch (e) {
         // this error will be detected later, so ignore it
-        spec = {paths: {}, 'x-default-params': {}};
+        spec = { paths: {}, 'x-default-params': {} };
     }
 
     return spec;
@@ -29,30 +33,32 @@
 
 function validateExamples(pathStr, defParams, mSpec) {
 
-    var uri = new URI(pathStr, {}, true);
+    const uri = new URI(pathStr, {}, true);
 
-    if(!mSpec) {
+    if (!mSpec) {
         try {
             uri.expand(defParams);
             return true;
-        } catch(e) {
-            throw new Error('Missing parameter for route ' + pathStr + ' : ' + 
e.message);
+        } catch (e) {
+            throw new Error(`Missing parameter for route ${pathStr} : 
${e.message}`);
         }
     }
 
-    if(!Array.isArray(mSpec)) {
-        throw new Error('Route ' + pathStr + ' : x-amples must be an array!');
+    if (!Array.isArray(mSpec)) {
+        throw new Error(`Route ${pathStr} : x-amples must be an array!`);
     }
 
-    mSpec.forEach(function(ex, idx) {
-        if(!ex.title) {
-            throw new Error('Route ' + pathStr + ', example ' + idx + ': title 
missing!');
+    mSpec.forEach((ex, idx) => {
+        if (!ex.title) {
+            throw new Error(`Route ${pathStr}, example ${idx}: title 
missing!`);
         }
         ex.request = ex.request || {};
         try {
             uri.expand(Object.assign({}, defParams, ex.request.params || {}));
-        } catch(e) {
-            throw new Error('Route ' + pathStr + ', example ' + idx + ' (' + 
ex.title + '): missing parameter: ' + e.message);
+        } catch (e) {
+            throw new Error(
+                `Route ${pathStr}, example ${idx} (${ex.title}): missing 
parameter: ${e.message}`
+            );
         }
     });
 
@@ -64,10 +70,10 @@
 function constructTestCase(title, path, method, request, response) {
 
     return {
-        title: title,
+        title,
         request: {
             uri: server.config.uri + (path[0] === '/' ? path.substr(1) : path),
-            method: method,
+            method,
             headers: request.headers || {},
             query: request.query,
             body: request.body,
@@ -85,31 +91,30 @@
 
 function constructTests(paths, defParams) {
 
-    var ret = [];
+    const ret = [];
 
-    Object.keys(paths).forEach(function(pathStr) {
-        Object.keys(paths[pathStr]).forEach(function(method) {
-            var p = paths[pathStr][method];
-            var uri;
-            if(p.hasOwnProperty('x-monitor') && !p['x-monitor']) {
+    Object.keys(paths).forEach((pathStr) => {
+        Object.keys(paths[pathStr]).forEach((method) => {
+            const p = paths[pathStr][method];
+            if ({}.hasOwnProperty.call(p, 'x-monitor') && !p['x-monitor']) {
                 return;
             }
-            uri = new URI(pathStr, {}, true);
-            if(!p['x-amples']) {
+            const uri = new URI(pathStr, {}, true);
+            if (!p['x-amples']) {
                 ret.push(constructTestCase(
                     pathStr,
-                    uri.toString({params: defParams}),
+                    uri.toString({ params: defParams }),
                     method,
                     {},
                     {}
                 ));
                 return;
             }
-            p['x-amples'].forEach(function(ex) {
+            p['x-amples'].forEach((ex) => {
                 ex.request = ex.request || {};
                 ret.push(constructTestCase(
                     ex.title,
-                    uri.toString({params: Object.assign({}, defParams, 
ex.request.params || {})}),
+                    uri.toString({ params: Object.assign({}, defParams, 
ex.request.params || {}) }),
                     method,
                     ex.request,
                     ex.response || {}
@@ -125,51 +130,52 @@
 
 function cmp(result, expected, errMsg) {
 
-    if(expected === null || expected === undefined) {
+    if (expected === null || expected === undefined) {
         // nothing to expect, so we can return
         return true;
     }
-    if(result === null || result === undefined) {
+    if (result === null || result === undefined) {
         result = '';
     }
 
-    if(expected.constructor === Object) {
-        Object.keys(expected).forEach(function(key) {
-            var val = expected[key];
-            assert.deepEqual(result.hasOwnProperty(key), true, 'Body field ' + 
key + ' not found in response!');
-            cmp(result[key], val, key + ' body field mismatch!');
+    if (expected.constructor === Object) {
+        Object.keys(expected).forEach((key) => {
+            const val = expected[key];
+            assert.deepEqual({}.hasOwnProperty.call(result, key), true,
+                `Body field ${key} not found in response!`);
+            cmp(result[key], val, `${key} body field mismatch!`);
         });
         return true;
-    } else if(expected.constructor === Array) {
-        if(result.constructor !== Array) {
+    } else if (expected.constructor === Array) {
+        if (result.constructor !== Array) {
             assert.deepEqual(result, expected, errMsg);
             return true;
         }
         // only one item in expected - compare them all
-        if(expected.length === 1 && result.length > 1) {
-            result.forEach(function(item) {
+        if (expected.length === 1 && result.length > 1) {
+            result.forEach((item) => {
                 cmp(item, expected[0], errMsg);
             });
             return true;
         }
         // more than one item expected, check them one by one
-        if(expected.length !== result.length) {
+        if (expected.length !== result.length) {
             assert.deepEqual(result, expected, errMsg);
             return true;
         }
-        expected.forEach(function(item, idx) {
+        expected.forEach((item, idx) => {
             cmp(result[idx], item, errMsg);
         });
         return true;
     }
 
-    if(expected.length > 1 && expected[0] === '/' && expected[expected.length 
- 1] === '/') {
-        if((new RegExp(expected.slice(1, -1))).test(result)) {
+    if (expected.length > 1 && expected[0] === '/' && expected[expected.length 
- 1] === '/') {
+        if ((new RegExp(expected.slice(1, -1))).test(result)) {
             return true;
         }
-    } else if(expected.length === 0 && result.length === 0) {
+    } else if (expected.length === 0 && result.length === 0) {
         return true;
-    } else if(result === expected || result.startsWith(expected)) {
+    } else if (result === expected || result.startsWith(expected)) {
         return true;
     }
 
@@ -181,24 +187,25 @@
 
 function validateTestResponse(testCase, res) {
 
-    var expRes = testCase.response;
+    const expRes = testCase.response;
 
     // check the status
     assert.status(res, expRes.status);
     // check the headers
-    Object.keys(expRes.headers).forEach(function(key) {
-        var val = expRes.headers[key];
-        assert.deepEqual(res.headers.hasOwnProperty(key), true, 'Header ' + 
key + ' not found in response!');
-        cmp(res.headers[key], val, key + ' header mismatch!');
+    Object.keys(expRes.headers).forEach((key) => {
+        const val = expRes.headers[key];
+        assert.deepEqual({}.hasOwnProperty.call(res.headers, key), true,
+            `Header ${key} not found in response!`);
+        cmp(res.headers[key], val, `${key} header mismatch!`);
     });
     // check the body
-    if(!expRes.body) {
+    if (!expRes.body) {
         return true;
     }
     res.body = res.body || '';
-    if(Buffer.isBuffer(res.body)) { res.body = res.body.toString(); }
-    if(expRes.body.constructor !== res.body.constructor) {
-        if(expRes.body.constructor === String) {
+    if (Buffer.isBuffer(res.body)) { res.body = res.body.toString(); }
+    if (expRes.body.constructor !== res.body.constructor) {
+        if (expRes.body.constructor === String) {
             res.body = JSON.stringify(res.body);
         } else {
             res.body = JSON.parse(res.body);
@@ -220,19 +227,19 @@
 describe('Swagger spec', function() {
 
     // the variable holding the spec
-    var spec = staticSpecLoad();
+    let spec = staticSpecLoad();
     // default params, if given
-    var defParams = spec['x-default-params'] || {};
+    let defParams = spec['x-default-params'] || {};
 
-    this.timeout(20000);
+    this.timeout(20000); // eslint-disable-line no-invalid-this
 
-    before(function () {
+    before(() => {
         return server.start();
     });
 
-    it('get the spec', function() {
-        return preq.get(server.config.uri + '?spec')
-        .then(function(res) {
+    it('get the spec', () => {
+        return preq.get(`${server.config.uri}?spec`)
+        .then((res) => {
             assert.status(200);
             assert.contentType(res, 'application/json');
             assert.notDeepEqual(res.body, undefined, 'No body received!');
@@ -240,25 +247,24 @@
         });
     });
 
-    it('spec validation', function() {
-        if(spec['x-default-params']) {
+    it('spec validation', () => {
+        if (spec['x-default-params']) {
             defParams = spec['x-default-params'];
         }
         // check the high-level attributes
-        ['info', 'swagger', 'paths'].forEach(function(prop) {
-            assert.deepEqual(!!spec[prop], true, 'No ' + prop + ' field 
present!');
+        ['info', 'swagger', 'paths'].forEach((prop) => {
+            assert.deepEqual(!!spec[prop], true, `No ${prop} field present!`);
         });
         // no paths - no love
         assert.deepEqual(!!Object.keys(spec.paths), true, 'No paths given in 
the spec!');
         // now check each path
-        Object.keys(spec.paths).forEach(function(pathStr) {
-            var path;
+        Object.keys(spec.paths).forEach((pathStr) => {
             assert.deepEqual(!!pathStr, true, 'A path cannot have a length of 
zero!');
-            path = spec.paths[pathStr];
-            assert.deepEqual(!!Object.keys(path), true, 'No methods defined 
for path: ' + pathStr);
-            Object.keys(path).forEach(function(method) {
-                var mSpec = path[method];
-                if(mSpec.hasOwnProperty('x-monitor') && !mSpec['x-monitor']) {
+            const path = spec.paths[pathStr];
+            assert.deepEqual(!!Object.keys(path), true, `No methods defined 
for path: ${pathStr}`);
+            Object.keys(path).forEach((method) => {
+                const  mSpec = path[method];
+                if ({}.hasOwnProperty.call(mSpec, 'x-monitor') && 
!mSpec['x-monitor']) {
                     return;
                 }
                 validateExamples(pathStr, defParams, mSpec['x-amples']);
@@ -266,14 +272,14 @@
         });
     });
 
-    describe('routes', function() {
+    describe('routes', () => {
 
-        constructTests(spec.paths, defParams).forEach(function(testCase) {
-            it(testCase.title, function() {
+        constructTests(spec.paths, defParams).forEach((testCase) => {
+            it(testCase.title, () => {
                 return preq(testCase.request)
-                .then(function(res) {
+                .then((res) => {
                     validateTestResponse(testCase, res);
-                }, function(err) {
+                }, (err) => {
                     validateTestResponse(testCase, err);
                 });
             });
diff --git a/test/features/info/info.js b/test/features/info/info.js
index a866dfb..38aeb8e 100644
--- a/test/features/info/info.js
+++ b/test/features/info/info.js
@@ -1,58 +1,62 @@
 'use strict';
 
 
-var preq   = require('preq');
-var assert = require('../../utils/assert.js');
-var server = require('../../utils/server.js');
+const preq   = require('preq');
+const assert = require('../../utils/assert.js');
+const server = require('../../utils/server.js');
 
+if (!server.stopHookAdded) {
+    server.stopHookAdded = true;
+    after(() => server.stop());
+}
 
 describe('service information', function() {
 
     this.timeout(20000);
 
-    before(function () { return server.start(); });
+    before(() => { return server.start(); });
 
     // common URI prefix for info tests
-    var infoUri = server.config.uri + '_info/';
+    const infoUri = server.config.uri + '_info/';
 
     // common function used for generating requests
     // and checking their return values
     function checkRet(fieldName) {
         return preq.get({
             uri: infoUri + fieldName
-        }).then(function(res) {
+        }).then((res) => {
             // check the returned Content-Type header
             assert.contentType(res, 'application/json');
             // the status as well
             assert.status(res, 200);
             // finally, check the body has the specified field
             assert.notDeepEqual(res.body, undefined, 'No body returned!');
-            assert.notDeepEqual(res.body[fieldName], undefined, 'No ' + 
fieldName + ' field returned!');
+            assert.notDeepEqual(res.body[fieldName], undefined, `No 
${fieldName} field returned!`);
         });
     }
 
-    it('should get the service name', function() {
+    it('should get the service name', () => {
         return checkRet('name');
     });
 
-    it('should get the service version', function() {
+    it('should get the service version', () => {
         return checkRet('version');
     });
 
-    it('should redirect to the service home page', function() {
+    it('should redirect to the service home page', () => {
         return preq.get({
-            uri: infoUri + 'home',
+            uri: `${infoUri}home`,
             followRedirect: false
-        }).then(function(res) {
+        }).then((res) => {
             // check the status
             assert.status(res, 301);
         });
     });
 
-    it('should get the service info', function() {
+    it('should get the service info', () => {
         return preq.get({
             uri: infoUri
-        }).then(function(res) {
+        }).then((res) => {
             // check the status
             assert.status(res, 200);
             // check the returned Content-Type header
@@ -65,6 +69,5 @@
             assert.notDeepEqual(res.body.home, undefined, 'No home field 
returned!');
         });
     });
-
 });
 
diff --git a/test/index.js b/test/index.js
index 535299f..b145c8a 100644
--- a/test/index.js
+++ b/test/index.js
@@ -3,4 +3,9 @@
 
 // Run jshint as part of normal testing
 require('mocha-jshint')();
-
+require('mocha-eslint')([
+    'lib',
+    'routes'
+], {
+    timeout: 10000
+});
diff --git a/test/utils/server.js b/test/utils/server.js
index bb75e99..b3a2b75 100644
--- a/test/utils/server.js
+++ b/test/utils/server.js
@@ -5,24 +5,24 @@
 /* global describe, it, before, beforeEach, after, afterEach */
 
 
-var BBPromise = require('bluebird');
-var ServiceRunner = require('service-runner');
-var logStream = require('./logStream');
-var fs = require('fs');
-var assert = require('./assert');
-var yaml = require('js-yaml');
-var extend = require('extend');
+const BBPromise = require('bluebird');
+const ServiceRunner = require('service-runner');
+const logStream = require('./logStream');
+const fs = require('fs');
+const assert = require('./assert');
+const yaml = require('js-yaml');
+const extend = require('extend');
 
 
 // set up the configuration
-var config = {
-    conf: yaml.safeLoad(fs.readFileSync(__dirname + '/../../config.dev.yaml'))
+let config = {
+    conf: yaml.safeLoad(fs.readFileSync(`${__dirname}/../../config.yaml`))
 };
 // build the API endpoint URI by supposing the actual service
 // is the last one in the 'services' list in the config file
-var myServiceIdx = config.conf.services.length - 1;
-var myService = config.conf.services[myServiceIdx];
-config.uri = 'http://localhost:' + myService.conf.port + '/';
+const myServiceIdx = config.conf.services.length - 1;
+const myService = config.conf.services[myServiceIdx];
+config.uri = `http://localhost:${myService.conf.port}/`;
 config.service = myService;
 // no forking, run just one process when testing
 config.conf.num_workers = 0;
@@ -33,11 +33,11 @@
     stream: logStream()
 };
 // make a deep copy of it for later reference
-var origConfig = extend(true, {}, config);
+const origConfig = extend(true, {}, config);
 
-var stop = function() { return BBPromise.resolve(); };
-var options = null;
-var runner = new ServiceRunner();
+module.exports.stop = () => { return BBPromise.resolve(); };
+let options = null;
+const runner = new ServiceRunner();
 
 
 function start(_options) {
@@ -45,18 +45,23 @@
     _options = _options || {};
 
     if (!assert.isDeepEqual(options, _options)) {
-        console.log('server options changed; restarting');
-        return stop().then(function() {
+        console.log('starting test server'); // eslint-disable-line no-console
+        return module.exports.stop().then(() => {
             options = _options;
             // set up the config
             config = extend(true, {}, origConfig);
             extend(true, config.conf.services[myServiceIdx].conf, options);
             return runner.start(config.conf)
-            .then(function() {
-                stop = function () {
-                    console.log('stopping test server');
-                    return runner.stop().then(function() {
-                        stop = function() { return BBPromise.resolve(); };
+            .then((serviceReturns) => {
+                module.exports.stop = () => {
+                    console.log('stopping test server'); // 
eslint-disable-line no-console
+                    serviceReturns.forEach(servers =>
+                        servers.forEach(server =>
+                            server.shutdown()));
+                    return runner.stop().then(function () {
+                        module.exports.stop = function () {
+                            return BBPromise.resolve();
+                        };
                     });
                 };
                 return true;
@@ -65,7 +70,6 @@
     } else {
         return BBPromise.resolve();
     }
-
 }
 
 module.exports.config = config;

-- 
To view, visit https://gerrit.wikimedia.org/r/390989
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: Ia35b7bd3d5d030e6f3fc4e0ac629636cda37c3a8
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/services/mathoid
Gerrit-Branch: master
Gerrit-Owner: Mobrovac <mobro...@wikimedia.org>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to