BohemiaInteractive/bi-service

View on GitHub
lib/express/app.js

Summary

Maintainability
A
2 hrs
Test Coverage
'use strict';

module.exports = App;

const url     = require('url');
const _       = require('lodash');
const logger  = require('bi-logger');
const http    = require('http');
const https   = require('https');
const Promise = require('bluebird');
const Express = require('express');

const AppStatus               = require('../common/appStatus.js');
const AppI                    = require('../common/app.js');
const Router                  = require('./router.js');
const routeNotFoundMiddleware = require('../middleware/routeNotFound');
const errorHandlerMiddleware  = require('../middleware/errorHandler');
const reqContentType          = require('../middleware/requestContentType.js');
const reqIdentityMiddleware   = require('../middleware/requestIdentity.js');
const appStatusCheckMiddleware= require('../middleware/appStatusCheck.js');

/**
 * App is `http` impementation of {@link App AppInterface} and represents
 * a bundle of {@link Router Routers} with {@link Route Routes}. It holds http[s] server object
 * or its equivalent/replacement and references to the {@link AppManager} and {@link Service}
 * instances which it was created from. It also manages its own `Config` instance with restricted scope
 *
 * @param {AppManager}   appManager
 * @param {Config}       config - module
 * @param {Object}       options
 * @param {String}       options.name - app's name
 * @param {Object}       [options.validator] - Ajv validator initialization options
 * @param {Object|Array} [options.validator.schemas] - list of globally accessible schema definitions
 *
 * @emits App#status-changed
 * @emits App#pre-init
 * @emits App#post-init
 * @emits App#pre-build
 * @emits App#post-build
 * @emits App#build-router
 * @emits App#listening
 * @emits App#error
 * @emits App#unknown-error
 * @emits App#error-response
 * @extends AppInterface
 * @constructor
 **/
function App(appManager, config, options) {
    const app = this;
    this.expressApp = Express();

    //App specific Router
    /**
     * App specific Router constructor
     * @name App#Router
     * @instance
     * @readonly
     * @type {Function}
     */
    this.Router = function() {
        Router.apply(this, arguments);
    };
    this.Router.prototype = Object.create(Router.prototype);
    this.Router.prototype.constructor = Router;
    this.Router.prototype.App = this;

    //parent constructor
    AppI.call(this, appManager, config, options);
    app.$normalizeConfig();
};

App.prototype = Object.create(AppI.prototype);
App.prototype.constructor = App;

/**
 * @private
 * @return {undefined}
 */
App.prototype.$normalizeConfig = function() {

    // set basePath
    var rootPath = this.config.get('baseUrl') || '';
    var host, protocol;
    if (rootPath) {
        //an url without protocol are not valid according to specs
        if (!rootPath.match(/^http(s)?/)) {
            rootPath = 'http://' + rootPath;
        }
        rootPath = url.parse(rootPath);
        host = rootPath.host;
        protocol = rootPath.protocol;
        rootPath = rootPath.pathname || '';
    }

    this.config.set('basePath', rootPath);
    this.config.set('host', host || '');
    this.config.set('protocol', protocol || '');
};

/**
 * @private
 * @return {undefined}
 */
App.prototype.$init = function() {

    var self = this;
    this.expressApp.locals.getUrl = function getUrl(uid, pathParams, queryParams) {
        return self.getRoute(uid).getUrl(pathParams, queryParams);
    };

    this.on('init', function(app) {
        var options = this.options;
        var headers = this.config.get('response:headers') || [];

        //generates unique uid for each request
        app.use(reqIdentityMiddleware.bind(app));


        app.expressApp.set('trust proxy', 'uniquelocal');
        //app.expressApp.set('view engine', 'ejs');
        app.expressApp.disable('x-powered-by');

        // Set default response headers & make sure req.body is an object
        // & path res object with custom methods
        app.use(function(req, res, next) {

            res.removeHeader('server', '*');
            headers.forEach(function(header) {
                res.setHeader.apply(res, header);
            });

            if (!req.body) {
                req.body = {};
            }

            return next();
        });

        if (app.config.get('stopOnError') === true) {
            app.use(appStatusCheckMiddleware.bind(app));
        }

        // Express global error handling
        app.once('post-build', function(app) {
            app.use('*', routeNotFoundMiddleware.bind(app));
            app.use(errorHandlerMiddleware.bind(app));
        });

        app.on('status-changed', function(status) {
            if (   status === AppStatus.ERROR
                && app.config.get('stopOnError') === true
            ) {
                logger.error(`The ${app.options.name} app has stopped processing all requests to prevent any further data damage`);
            }
        });

        //default error response fallback,
        //`error-response` listeners are handled asynchronously in a series
        app.on('error-response', function defaultResponse(err, res) {
            if (!res.headersSent) {
                res.json(err);
            }
        });

        app.prependListener('error-response', function setStatusCode(err, res) {
            res.status(err.code);
        });

    });

    return AppI.prototype.$init.call(this);
};

/**
 * returns protocol + host url string
 * @return {String}
 */
App.prototype.getHost = function() {
    return `${this.config.get('protocol')}//${this.config.get('host')}`;
};

/**
 * registers connect-session middleware
 * @param {CacheStoreInterface} store
 *
 * @deprecated
 * @return {CacheStoreInterface}
 */
App.prototype.useSession = function(store) {

    var sessionOpt  = _.cloneDeep(this.config.get('session'));
    sessionOpt.store = store;
    this.use(require('express-session')(sessionOpt));

    return store;
};

/**
 * bind application-level middleware that will be run before `Route`'s
 * middleware stack
 *
 * @param {String} [endpoint]
 * @param {Function} [callback]
 *
 * @return {undefined}
 */
App.prototype.use = function() {
    var args = Array.prototype.slice.call(arguments, 0);
    return this.expressApp.use.apply(this.expressApp, args);
};

/**
 * @private
 * @return {ExpressRouter}
 */
App.prototype.$buildExpressRouter = function() {
    return Express.Router();
};

/**
 * @param {Integer} defaultValue
 *
 * @private
 * @return {Integer}
 */
App.prototype.$getTimeoutInterval = function(defaultValue) {
    var timeout = this.config.get('request:timeout');
    if (typeof timeout === 'number') {
        return timeout;
    } else if (typeof defaultValue === 'number') {
        return defaultValue;
    }
    return 0;
};

/**
 * @private
 * @return {App}
 */
App.prototype.build = function() {
    var app = this;

    process.nextTick(function() {
        app.emit('pre-build', app);

        app.routers.forEach(function(router) {
            app.expressApp.use(router.getUrl(), router.$buildExpressRouter());
        });

        app.emit('post-build', app);
    });

    return app;
};

/**
 * start http(s) server listening on configured port
 *
 * @param {Integer|String} port - or socket
 * @param {String}         [hostname]
 * @param {Integer}        [backlog] - the maximum length of the queue of pending connections. The actual length will be determined by your OS through sysctl settings such as tcp_max_syn_backlog and somaxconn on linux. The default value of this parameter is 511 (not 512).
 * @param {Object}         [options]
 * @param {Boolean}        [options.ssl=false]
 *
 * @return http[s].Server
 */
App.prototype.listen = function() {
    var args = Array.prototype.slice.call(arguments, 0, 3);
    var app = this;
    var options = {
        ssl: false,
        cli: false
    };

    if (app.status === AppStatus.ERROR) {
        throw app.statusReason;
    }

    if (app.server !== null) {
        //if we needed the app to listen on both https and http, we should handle this on system level
        throw new Error('Another Server is already running.');
    }

    if (_.isPlainObject(arguments[arguments.length -1])) {
        options = _.assign(options, arguments[arguments.length -1])
    }

    var protocol = options.ssl ? https : http;

    app.server = protocol.createServer(app.expressApp);
    app.server.setTimeout(this.$getTimeoutInterval(10000));//10s

    app.server.on('error', function(err) {
        app.emit('error', err);
    });
    app.server.once('listening', function() {
        app.$setStatus(AppStatus.OK);
        app.emit('listening', app);
    });

    return app.server.listen.apply(app.server, args);
};