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