jenkins-bot has submitted this change and it was merged.

Change subject: Update to template v0.2.1 and add the monitoring spec
......................................................................


Update to template v0.2.1 and add the monitoring spec

We now have a monitoring utility able to monitor specific endpoints
exposed by the service at /?spec . This patch creates and exposes a
simple monitoring spec for Mathoid.

Also, some node modules have been updated and the Mathoid-specific route
has been moved over to routes/mathoid.js.

Bug: T105775
Change-Id: Id5e06852ccb4d1dc12b9376e690965c8e742ce18
---
M app.js
M config.dev.yaml
M lib/util.js
M package.json
A routes/mathoid.js
M routes/root.js
A spec.yaml
A test/features/app/spec.js
8 files changed, 557 insertions(+), 103 deletions(-)

Approvals:
  Physikerwelt: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/app.js b/app.js
index 76dec28..ea11449 100644
--- a/app.js
+++ b/app.js
@@ -15,6 +15,7 @@
 var sUtil = require('./lib/util');
 var packageInfo = require('./package.json');
 var mjAPI = require("MathJax-node/lib/mj-single.js");
+var yaml = require('js-yaml');
 
 
 /**
@@ -46,6 +47,40 @@
     // set outgoing 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)) {
+                process.env.NO_PROXY = app.conf.no_proxy_list.join(',');
+            } else {
+                process.env.NO_PROXY = app.conf.no_proxy_list;
+            }
+        }
+    }
+
+    // set up the spec
+    if(!app.conf.spec) {
+        app.conf.spec = __dirname + '/spec.yaml';
+    }
+    try {
+        app.conf.spec = yaml.safeLoad(fs.readFileSync(app.conf.spec));
+    } catch(e) {
+        app.logger.log('warn/spec', 'Could not load the spec: ' + e);
+        app.conf.spec = {};
+    }
+    if(!app.conf.spec.swagger) {
+        app.conf.spec.swagger = '2.0';
+    }
+    if(!app.conf.spec.info) {
+        app.conf.spec.info = {
+            version: app.info.version,
+            title: app.info.name,
+            description: app.info.description
+        };
+    }
+    app.conf.spec.info.version = app.info.version;
+    if(!app.conf.spec.paths) {
+        app.conf.spec.paths = {};
     }
 
     // set the CORS and CSP headers
diff --git a/config.dev.yaml b/config.dev.yaml
index f298895..cca23e7 100644
--- a/config.dev.yaml
+++ b/config.dev.yaml
@@ -33,6 +33,20 @@
       port: 10042
       # interface: localhost # uncomment to only listen on localhost
       # more per-service config settings
+      # the location of the spec, defaults to spec.yaml if not specified
+      # spec: ./spec.template.yaml
+      # allow cross-domain requests to the API (default '*')
+      cors: '*'
+      # to disable use:
+      # cors: false
+      # to restrict to a particular domain, use:
+      # cors: restricted.domain.org
+      # URL of the outbound proxy to use (complete with protocol)
+      # proxy: http://my.proxy.org:8080
+      # the list of domains for which not to use the proxy defined above
+      # no_proxy_list:
+      #   - domain1.com
+      #   - domain2.org
       svg: true
       img: true
       png: true #new feature
diff --git a/lib/util.js b/lib/util.js
index 3aef6da..5b6bbb6 100644
--- a/lib/util.js
+++ b/lib/util.js
@@ -4,7 +4,7 @@
 var BBPromise = require('bluebird');
 var util = require('util');
 var express = require('express');
-var uuid = require('node-uuid');
+var uuid = require('cassandra-uuid');
 var bunyan = require('bunyan');
 
 
@@ -86,11 +86,9 @@
  *
  * @return {String} the generated request ID
  */
-var reqIdBuff = new Buffer(16);
 function generateRequestId() {
 
-    uuid.v4(null, reqIdBuff);
-    return reqIdBuff.toString('hex');
+    return uuid.TimeUuid.now().toString();
 
 }
 
diff --git a/package.json b/package.json
index 94baa99..b3a5bfb 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "mathoid",
   "version": "0.2.8",
-  "description": "Render tex to SVG and MathML using MathJax. Based on 
svgtex.",
+  "description": "Render TeX to SVG and MathML using MathJax. Based on 
svgtex.",
   "main": "./mathoid.js",
   "scripts": {
     "start": "service-runner",
@@ -36,29 +36,26 @@
   },
   "homepage": "https://github.com/wikimedia/mathoid";,
   "dependencies": {
-    "bluebird": "^2.9.24",
-    "body-parser": "^1.12.3",
-    "bunyan": "^1.3.5",
-    "compression": "^1.4.3",
+    "bluebird": "~2.8.2",
+    "body-parser": "^1.13.2",
+    "bunyan": "^1.4.0",
+    "cassandra-uuid": "^0.0.2",
+    "compression": "^1.5.1",
     "domino": "^1.0.18",
-    "express": "^4.12.3",
-    "js-yaml": "^3.2.7",
-    "preq": "^0.3.13",
-    "service-runner": "^0.1.8",
-    "jsdom": "0.11.1",
-    "node-uuid": "^1.4.3",
-    "querystring": "0.x.x",
-    "request": "2.x.x",
+    "express": "^4.13.1",
+    "js-yaml": "^3.3.1",
+    "preq": "^0.4.4",
+    "service-runner": "^0.2.1",
     "texvcjs": "git+https://github.com/wikimedia/texvcjs";,
     "MathJax-node": 
"git+https://github.com/wikimedia/MathJax-node#mathoid-0-2-8";
   },
   "devDependencies": {
-    "assert": "^1.3.0",
-    "istanbul": "^0.3.13",
-    "mocha": "^2.2.4",
-    "mocha-jshint": "0.0.9",
-    "mocha-lcov-reporter": "0.0.1",
-    "coveralls": "2.11.2"
+    "coveralls": "2.11.2",
+    "istanbul": "^0.3.17",
+    "mocha": "^2.2.5",
+    "mocha-jshint": "^2.2.3",
+    "mocha-lcov-reporter": "^0.0.2",
+    "swagger-router": "^0.1.1"
   },
   "deploy": {
     "target": "ubuntu",
diff --git a/routes/mathoid.js b/routes/mathoid.js
new file mode 100644
index 0000000..ab1e527
--- /dev/null
+++ b/routes/mathoid.js
@@ -0,0 +1,120 @@
+'use strict';
+
+
+var sUtil = require('../lib/util');
+var texvcjs = require('texvcjs');
+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;
+
+
+function handleRequest(res, q, type) {
+    var mml = true;
+    var sanitizedTex;
+    //Keep format variables constant
+    if (type === "tex") {
+        type = "TeX";
+        var sanitizationOutput = texvcjs.check(q);
+        // XXX properly handle errors here!
+        if (sanitizationOutput.status === '+') {
+            sanitizedTex = sanitizationOutput.output || '';
+            q = sanitizedTex;
+        } else {
+            throw new HTTPError
+            ({
+                status: 400,
+                log: sanitizationOutput.status + ': ' + 
sanitizationOutput.details,
+                success: false
+            });
+        }
+    }
+    if (type === "mml" || type === "MathML") {
+        type = "MathML";
+        mml = false;
+    }
+    if (type === "ascii" || type === "asciimath") {
+        type = "AsciiMath";
+    }
+    app.mjAPI.typeset( {
+        math: q,
+        format: type,
+        svg: app.conf.svg,
+        img: app.conf.img,
+        mml: mml,
+        speakText: app.conf.speakText,
+        png: app.conf.png }, function (data) {
+        if (data.errors) {
+            data.success = false;
+            data.log = "Error:" + JSON.stringify(data.errors);
+        } else {
+            data.success = true;
+            data.log = "success";
+        }
+
+        // Strip some styling returned by MathJax
+        if (data.svg) {
+            data.svg = data.svg.replace(/style="([^"]+)"/, function(match, 
style) {
+                return 'style="'
+                    + style.replace(/(?:margin(?:-[a-z]+)?|position):[^;]+; 
*/g, '')
+                    + '"';
+            });
+        }
+
+        // Return the sanitized TeX to the client
+        if (sanitizedTex !== undefined) {
+            data.sanetex = sanitizedTex;
+        }
+
+        res.writeHead(200, {
+            'Content-Type': 'application/json'
+        });
+        res.end(JSON.stringify(data));
+    });
+}
+
+
+/**
+ * POST /
+ * Performs the rendering request
+ */
+router.post(/^\/$/, function(req, res) {
+
+    // First some rudimentary input validation
+    if (!(req.body.q)) {
+        throw new HTTPError
+        ({
+            status: 400,
+            error: "q (query) post parameter is missing!"
+        });
+    }
+    var q = req.body.q;
+    var type = req.body.type;
+    if (!(req.body.type) ){
+        type = 'tex';
+    }
+    handleRequest(res, q, type);
+
+});
+
+
+module.exports = function(appObj) {
+
+    app = appObj;
+
+    return {
+        path: '/',
+        skip_domain: true,
+        router: router
+    };
+
+};
+
diff --git a/routes/root.js b/routes/root.js
index ce0027a..3b34d8e 100644
--- a/routes/root.js
+++ b/routes/root.js
@@ -8,13 +8,12 @@
  * The main router object
  */
 var router = sUtil.router();
-var texvcjs = require('texvcjs');
-var HTTPError = sUtil.HTTPError;
 
 /**
  * The main application object reported when this module is require()d
  */
 var app;
+
 
 /**
  * GET /robots.txt
@@ -29,89 +28,19 @@
 
 });
 
-function handleRequest(res, q, type) {
-    var mml = true;
-    var sanitizedTex;
-    //Keep format variables constant
-    if (type === "tex") {
-        type = "TeX";
-        var sanitizationOutput = texvcjs.check(q);
-        // XXX properly handle errors here!
-        if (sanitizationOutput.status === '+') {
-            sanitizedTex = sanitizationOutput.output || '';
-            q = sanitizedTex;
-        } else {
-            throw new HTTPError
-            ({
-                status: 400,
-                log: sanitizationOutput.status + ': ' + 
sanitizationOutput.details,
-                success: false
-            });
-        }
-    }
-    if (type === "mml" || type === "MathML") {
-        type = "MathML";
-        mml = false;
-    }
-    if (type === "ascii" || type === "asciimath") {
-        type = "AsciiMath";
-    }
-    app.mjAPI.typeset( {
-        math: q,
-        format: type,
-        svg: app.conf.svg,
-        img: app.conf.img,
-        mml: mml,
-        speakText: app.conf.speakText,
-        png: app.conf.png }, function (data) {
-        if (data.errors) {
-            data.success = false;
-            data.log = "Error:" + JSON.stringify(data.errors);
-        } else {
-            data.success = true;
-            data.log = "success";
-        }
 
-        // Strip some styling returned by MathJax
-        if (data.svg) {
-            data.svg = data.svg.replace(/style="([^"]+)"/, function(match, 
style) {
-                return 'style="'
-                    + style.replace(/(?:margin(?:-[a-z]+)?|position):[^;]+; 
*/g, '')
-                    + '"';
-            });
-        }
-
-        // Return the sanitized TeX to the client
-        if (sanitizedTex !== undefined) {
-            data.sanetex = sanitizedTex;
-        }
-
-        res.writeHead(200, {
-            'Content-Type': 'application/json'
-        });
-        res.end(JSON.stringify(data));
-    });
-}
 /**
- * GET /robots.txt
- * Instructs robots no indexing should occur on this domain.
+ * GET /
+ * Main entry point. Currently it only responds if the spec query
+ * parameter is given, otherwise lets the next middleware handle it
  */
-router.post(/^\/$/, function(req, res) {
+router.get('/', function(req, res, next) {
 
-    // First some rudimentary input validation
-    if (!(req.body.q)) {
-        throw new HTTPError
-        ({
-            status: 400,
-            error: "q (query) post parameter is missing!"
-        });
+    if(!(req.query || {}).hasOwnProperty('spec')) {
+        next();
+    } else {
+        res.json(app.conf.spec);
     }
-    var q = req.body.q;
-    var type = req.body.type;
-    if (!(req.body.type) ){
-        type = 'tex';
-    }
-    handleRequest(res, q, type);
 
 });
 
@@ -119,6 +48,7 @@
 module.exports = function(appObj) {
 
     app = appObj;
+
     return {
         path: '/',
         skip_domain: true,
diff --git a/spec.yaml b/spec.yaml
new file mode 100644
index 0000000..f4fd0d8
--- /dev/null
+++ b/spec.yaml
@@ -0,0 +1,75 @@
+swagger: '2.0'
+info:
+  version: 0.2.8
+  title: Mathoid
+  description: Renders TeX to SVG and MathML using MathJax
+  termsOfService: https://wikimediafoundation.org/wiki/Terms_of_Use
+  contact:
+    name: the Wikimedia Services team
+    url: http://mediawiki.org/wiki/Services
+  license:
+    name: Apache2
+    url: http://www.apache.org/licenses/LICENSE-2.0
+paths:
+  # from routes/root.js
+  /robots.txt:
+    get:
+      tags:
+        - Root
+        - Robots
+      description: Gets robots.txt
+      x-monitor: false
+  /:
+    get:
+      tags:
+        - Root
+      description: The root service end-point
+      produces:
+        - application/json
+      x-amples:
+        - title: spec from root
+          request:
+            query:
+              spec: true
+          response:
+            status: 200
+  # from routes/info.js
+  /_info:
+    get:
+      tags:
+        - Service information
+      description: Gets information about the service
+      produces:
+        - application/json
+      x-amples:
+        - title: retrieve service info
+          request: {}
+          response:
+            status: 200
+            headers:
+              content-type: application/json
+            body:
+              name: /.+/
+              description: /.+/
+              version: /.+/
+              home: /.+/
+  # from routes/mathoid.js
+  /:
+    post:
+      tags:
+        - Render
+      description: Converts TeX to MathML and SVG
+      produces:
+        - application/json
+      x-amples:
+        - title: mass-energy equivalence
+          request:
+            body:
+              q: 'E=mc^2'
+          response:
+            status: 200
+            body:
+              log: success
+              success: true
+              svg: /.+/
+              mml: /.+/
diff --git a/test/features/app/spec.js b/test/features/app/spec.js
new file mode 100644
index 0000000..570fb40
--- /dev/null
+++ b/test/features/app/spec.js
@@ -0,0 +1,285 @@
+'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');
+
+
+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');
+
+    try {
+        spec = yaml.safeLoad(fs.readFileSync(specPath));
+    } catch(e) {
+        // this error will be detected later, so ignore it
+        spec = {paths: {}, 'x-default-params': {}};
+    }
+
+    return spec;
+
+}
+
+
+function validateExamples(pathStr, defParams, mSpec) {
+
+    var uri = new URI(pathStr, {}, true);
+
+    if(!mSpec) {
+        try {
+            uri.expand(defParams);
+            return true;
+        } 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!');
+    }
+
+    mSpec.forEach(function(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);
+        }
+    });
+
+    return true;
+
+}
+
+
+function constructTestCase(title, path, method, request, response) {
+
+    return {
+        title: title,
+        request: {
+            uri: server.config.uri + (path[0] === '/' ? path.substr(1) : path),
+            method: method,
+            headers: request.headers || {},
+            query: request.query,
+            body: request.body,
+            followRedirect: false
+        },
+        response: {
+            status: response.status || 200,
+            headers: response.headers || {},
+            body: response.body
+        }
+    };
+
+}
+
+
+function constructTests(paths, defParams) {
+
+    var 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']) {
+                return;
+            }
+            uri = new URI(pathStr, {}, true);
+            if(!p['x-amples']) {
+                ret.push(constructTestCase(
+                    pathStr,
+                    uri.toString({params: defParams}),
+                    method,
+                    {},
+                    {}
+                ));
+                return;
+            }
+            p['x-amples'].forEach(function(ex) {
+                ex.request = ex.request || {};
+                ret.push(constructTestCase(
+                    ex.title,
+                    uri.toString({params: Object.assign({}, defParams, 
ex.request.params || {})}),
+                    method,
+                    ex.request,
+                    ex.response || {}
+                ));
+            });
+        });
+    });
+
+    return ret;
+
+}
+
+
+function cmp(result, expected, errMsg) {
+
+    if(expected === null || expected === undefined) {
+        // nothing to expect, so we can return
+        return true;
+    }
+    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!');
+        });
+        return true;
+    } 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) {
+                cmp(item, expected[0], errMsg);
+            });
+            return true;
+        }
+        // more than one item expected, check them one by one
+        if(expected.length !== result.length) {
+            assert.deepEqual(result, expected, errMsg);
+            return true;
+        }
+        expected.forEach(function(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)) {
+            return true;
+        }
+    } else if(expected.length === 0 && result.length === 0) {
+        return true;
+    } else if(result === expected || result.startsWith(expected)) {
+        return true;
+    }
+
+    assert.deepEqual(result, expected, errMsg);
+    return true;
+
+}
+
+
+function validateTestResponse(testCase, res) {
+
+    var 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!');
+    });
+    // check the 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) {
+            res.body = JSON.stringify(res.body);
+        } else {
+            res.body = JSON.parse(res.body);
+        }
+    }
+    // check that the body type is the same
+    if(expRes.body.constructor !== res.body.constructor) {
+        throw new Error('Expected a body of type ' + expRes.body.constructor + 
' but gotten ' + res.body.constructor);
+    }
+
+    // compare the bodies
+    cmp(res.body, expRes.body, 'Body mismatch!');
+
+    return true;
+
+}
+
+
+describe('Swagger spec', function() {
+
+    // the variable holding the spec
+    var spec = staticSpecLoad();
+    // default params, if given
+    var defParams = spec['x-default-params'] || {};
+
+    this.timeout(20000);
+
+    before(function () {
+        return server.start();
+    });
+
+    it('get the spec', function() {
+        return preq.get(server.config.uri + '?spec')
+        .then(function(res) {
+            assert.status(200);
+            assert.contentType(res, 'application/json');
+            assert.notDeepEqual(res.body, undefined, 'No body received!');
+            spec = res.body;
+        });
+    });
+
+    it('spec validation', function() {
+        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!');
+        });
+        // 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;
+            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']) {
+                    return;
+                }
+                validateExamples(pathStr, defParams, mSpec['x-amples']);
+            });
+        });
+    });
+
+    describe('routes', function() {
+
+        constructTests(spec.paths, defParams).forEach(function(testCase) {
+            it(testCase.title, function() {
+                return preq(testCase.request)
+                .then(function(res) {
+                    validateTestResponse(testCase, res);
+                }, function(err) {
+                    validateTestResponse(testCase, err);
+                });
+            });
+        });
+
+    });
+
+});
+

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

Gerrit-MessageType: merged
Gerrit-Change-Id: Id5e06852ccb4d1dc12b9376e690965c8e742ce18
Gerrit-PatchSet: 5
Gerrit-Project: mediawiki/services/mathoid
Gerrit-Branch: master
Gerrit-Owner: Mobrovac <[email protected]>
Gerrit-Reviewer: GWicke <[email protected]>
Gerrit-Reviewer: Mobrovac <[email protected]>
Gerrit-Reviewer: Physikerwelt <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to