BohemiaInteractive/bi-service

View on GitHub
lib/common/route.js

Summary

Maintainability
C
1 day
Test Coverage
'use strict';

const _            = require('lodash');
const Promise      = require('bluebird');
const EventEmitter = require('events-bluebird');
const stackTrace   = require('stack-trace');
const typeis       = require('type-is');

const RouteError          = require('../error/routeError.js');
const Response            = require('../response.js');
const validatorMiddleware = require('../middleware/validator.js');
const errorHandler        = require('../middleware/errorHandler.js');

module.exports = Route;//aka. RouteInterface


/**
 * @param {Object} options
 * @param {String} [options.name]
 * @param {String} options.url
 * @param {String} options.summary - swagger doc
 * @param {String} options.desc - swagger doc
 * @param {String} options.sdkMethodName - client sdk method name
 *
 * @throws {RouteError}
 * @alias RouteInterface
 * @constructor
 **/
function Route(options) {
    var defaults = {
        name: null,
        url: null,
        summary: "",
        desc: "",
        sdkMethodName: ""
    };

    const route = this;
    this.options = _.assign(defaults, options || {});

    this.steps = [];
    this.description = {
        responses: {},
        summary: this.options.summary,
        description: this.options.desc,
        sdkMethodName: this.options.sdkMethodName
    };
    this.$dataParserMiddleware = null;
    this.$dataParser = null;

    if (!options.url) {
        throw new RouteError('Invalid request url');
    }

    if (!this.description.sdkMethodName) {
        this.description.sdkMethodName = this.$formatUid('{method}{Name}');
    }

    /**
     * unique identifier of the route
     * @name RouteInterface#uid
     * @instance
     * @readonly
     * @type {String}
     */
    this.uid = this.$formatUid(
        this.Router.options.routeNameFormat
    );

    /**
     * tries to determine the route definition location
     * @name RouteInterface#fileSystemLocation
     * @instance
     * @readonly
     * @type {String}
     */
    this.fileSystemLocation = this.$getFileSystemLocation();
    route.$setSupportedContentTypes();

    this.once('build', pushErrorHandler);
    this.once('build', pushNoop);
};

Route.prototype = Object.create(EventEmitter.prototype);
Route.prototype.constructor = Route;

/**
 * @private
 * @param {Function} fn - parser function will be provided with `req` object
 * @return {RouteInterface} - self
 */
Route.prototype.$setContentTypeParser = function(fn) {
    this.$dataParser = fn;
    return this;
};

/**
 * @private
 * @return {Function|null}
 */
Route.prototype.$getContentTypeParser = function(fn) {
    return this.$dataParser;
};

/**
 * @private
 * @return {undefined}
 */
Route.prototype.$setSupportedContentTypes = function() {
    let bodyParser = this.Router.App.config.get('bodyParser');

    if (!bodyParser) {
        return;
    }

    Object.keys(bodyParser).forEach(function(key) {
        let options = bodyParser[key];
        this.acceptsContentType(options.type || key, options);
    }, this);
};

/**
 * define which content-type headers the route supports.  
 * this is also gonna register and manage a single validation middleware for `content-type` header internally
 *
 * @param {String}   type - Content-Type header value
 * @param {Object}   [options]
 * @param {String}   [options.limit] - data size limit
 * @param {Function} [parser] - custom data parser function - must return a Promise
 * @return {RouteInterface}
 */
Route.prototype.acceptsContentType = function(type, options, parser) {

    type = typeis.normalize(type);

    if (this.$dataParserMiddleware === null) {
        this.$dataParserMiddleware = {
            name: 'content-type-parser',
            fn: function() {
                return this.route.$getContentTypeParser().apply(this, arguments);
            },
            pendingValidatorMiddleware: false,
            validatorMiddlewareIndex: this.steps.length,
            validatorMiddleware: null,
            contentTypes: {},
            mediaTypes: []
        };

        this.steps.push(this.$dataParserMiddleware);
    }

    if (typeof this.$dataParserMiddleware === 'object'
        && _.isPlainObject(this.$dataParserMiddleware.contentTypes)
        && !this.$dataParserMiddleware.contentTypes.hasOwnProperty(type)
    ) {
        this.$dataParserMiddleware.contentTypes[type] = _.assign(
            _.clone(options),
            {
                parser: parser,
                type: type,
            }
        );

        this.$dataParserMiddleware.mediaTypes.push(type);

        if (!this.$dataParserMiddleware.pendingValidatorMiddleware) {
            this.$dataParserMiddleware.pendingValidatorMiddleware = true;
            process.nextTick(
                _registerContentTypeValidatorMiddleware,
                this,
                this.$dataParserMiddleware
            );
        }
    }

    return this;
};

/**
 * @private
 * @param {Route} route
 */
function _registerContentTypeValidatorMiddleware(route) {
    const parser = route.$dataParserMiddleware;
    const index = parser.validatorMiddlewareIndex;
    let removeCount = 0;

    const schema = {
        type: 'object',
        properties: {
            'content-type': {
                type: 'string',
                format: 'media-type',
                pattern: `(${parser.mediaTypes.map(_.escapeRegExp).join('|')})`
            }
        }
    };

    if (route.steps[index] === parser.validatorMiddleware) {
        removeCount = 1;
    }

    parser.validatorMiddleware
        = route.$createValidatorMiddleware(schema, 'headers');

    route.steps.splice(
        index,
        removeCount,
        parser.validatorMiddleware
    );

    route.$dataParserMiddleware.pendingValidatorMiddleware = false;
}

/**
 * returns collection of supported request content mime types
 * @return {Array<String>}
 */
Route.prototype.acceptedContentTypes = function() {

    if (this.$dataParserMiddleware === null) {
        return [];
    }

    return [].concat(this.$dataParserMiddleware.mediaTypes);
};

/**
 * define a content-type which should be always rejected by this route.
 * Content types black listed by this method can be later white listed by the
 * {@link RouteInterface#acceptsContentType} method
 *
 * @param {String} type - Content-Type header value
 * @return {RouteInterface}
 */
Route.prototype.rejectsContentType = function(type) {
    if (   typeof this.$dataParserMiddleware === 'object'
        && this.$dataParserMiddleware !== null
    ) {
        let index = this.$dataParserMiddleware.mediaTypes.indexOf(type);
        delete this.$dataParserMiddleware.contentTypes[type];
        if (~index) {
            this.$dataParserMiddleware.mediaTypes.splice(index, 1);
        }
    }

    return this;
};

/**
 * should be called by internal code only. Otherwise it will return distorted result
 * @private
 * @return {String|null}
 */
Route.prototype.$getFileSystemLocation = function() {
    var location = null;

    //Get the file system location of the route definition
    try {
        var trace = stackTrace.get();
        for (var i = 0, path = null, len = trace.length; i < len; i++) {
            path = trace[i].getFileName();
            //pick first path which does not contain "node_modules/" directory
            if (   typeof path === 'string'
                && !path.match(/node_modules\//)
                && path.match(/^\/home\//)
            ) {
                location = path;
                break;
            }
        }
    } catch (e) { /* mute the error */ }

    return location;
};

/**
 * returns route's name. If no name has been assigned,
 * the name is dynamically created from route's url path
 *
 * @abstract
 * @return {String}
 */
Route.prototype.getName = function() {
    throw new Error('Not implemented by subclass');
};

/**
 * @private
 * @abstract
 * @param {String} format
 * @return {String}
 */
Route.prototype.$formatUid = function(format) {
    throw new Error('Not implemented by subclass');
};

/**
 * @example
 * route.main(() => {})
 *
 * //is same as:
 *
 * route.step('main', () => {})
 *
 * @param {Function} fn
 * @return {RouteInterface} - self
 */
Route.prototype.main = function(fn) {

    this.steps.push({
        name: 'main',
        fn: fn
    });

    return this;
};

/**
 * pushes specifically configured validation middleware to the route's call stack
 *
 * @example
 *
 * route.validate({
 *     properties: {
 *         username: {type: 'string'}
 *     }
 * }, 'query');
 *
 * //or
 *
 * route.validate('ajv-registered-validation-schema-uid', 'body');
 *
 * @param {string|Object} valDef - string => registered validator's name. Object => schema definition
 * @param {string}  dataProp - any of available properties of the `req` object - eg.: query|body|params|headers
 *
 * @return {RouteInterface} - self
 */
Route.prototype.validate = function() {
    var args = Array.prototype.slice.call(arguments, 0);

    //if literal ajv schema is provided make sure that expected data type is set
    //query,body,params,headers are expected to be all objects by default
    if (_.isPlainObject(args[0]) && !args[0].hasOwnProperty('type')) {
        args[0].type = 'object';
    }

    this.steps.push(this.$createValidatorMiddleware.apply(this, args));

    return this;
};

/**
 * @private
 * @param {string|Object} valDef - string => registered validator's name. Object => schema definition
 * @param {string}  dataProp - any of available properties of the `req` object - eg.: query|body|params|headers
 * @return {Object}
 */
Route.prototype.$createValidatorMiddleware = function() {
    const args = Array.prototype.slice.call(arguments, 0);
    return {
        name: 'validator',
        fn: validatorMiddleware.apply(this, _.cloneDeep(args)),
        args: _.cloneDeep(args)
    };
};

/**
 * allows to hook up any middleware function to the request promise chain (call stack)
 *
 * @param {String} [name]
 * @param {Function} fn
 * @return {RouteInterface} - self
 */
Route.prototype.addStep = function (name, fn) {
    if (typeof name === 'function') {
        fn = name;
        name = this.steps.length + 1;
    }

    if (this.steps.find(step => step.name == name)) {
        throw new RouteError('Route`s middleware name must be unique');
    }

    this.steps.push({
        name: name.toString(),
        fn: fn
    });

    return this;
};

/**
 * alias for {@link RouteInterface#addStep}
 *
 * @function
 * @param {String} [name]
 * @param {Function} fn
 * @return {RouteInterface} - self
 */
Route.prototype.step = Route.prototype.addStep;

/**
 * returns route's internal middleware call stack
 * @returns {Array<Object>}
 */
Route.prototype.getAllSteps = function () {
    return this.steps;
};

/**
 * allows to describe route's response data format in form of `Ajv` validation
 * schema object or `Error` instance object/constructor which implements `toSwagger` method.  
 * if a `string` is provided it's expected to be validation schema unique indentifier
 * registered with the Ajv instance.  
 * With `res.filter({some: 'data'}).json()` conjuction, data can be filtered
 * with defined response schema.
 *
 * @example
 *
 * route.respondsWith({
 *     type: 'object',
 *     additionalProperties: false,
 *     properties: {
 *         prop1: {type: 'string'}
 *     }
 * });
 *
 * route.main(function(req, res) {
 *     res.filter({prop1: 'included', prop2: 'filtered out'}).json();
 * });
 *
 * @param {Object|String|Function} descriptor
 * @return {RouteInterface} - self
 */
Route.prototype.respondsWith = function(descriptor) {
    var responses = this.description.responses;
    var code = 200;

    if (descriptor instanceof Function
        && Error.prototype.isPrototypeOf(descriptor.prototype)
        || descriptor.prototype instanceof Error
    ) {
        descriptor = new descriptor;
        code = descriptor.code;
    } else if (descriptor instanceof Error) {
        code = descriptor.code;
    }

    //if redpondsWith method is called multiple times with same type of Error,
    //eg.: route.respondsWith(new RequestError({apiCode: 'code1'})
    //     route.respondsWith(new RequestError({apiCode: 'code2'})
    //the two swagger schemas of errors will be merged so that we can show
    //for example all the api codes a route responds with.
    responses[code] = responses[code] || [];
    var schema = { schema: descriptor };

    //we support only single schema definition for a "success" response
    if (code === 200 && responses[code].length) {
        responses[code].splice(0, 1, schema);
    } else {
        responses[code].push(schema);
    }

    return this;
};

/**
 * catch promise stack handler invoked when an Error occurs while executing one
 * of the route middlwares
 *
 * @example
 * route.main(function() {
 *   throw new TypeError('test');
 * }).catch(TypeError, function(err, req, res) {
 *   //err handler logic
 * });
 *
 * @param {Function} [filter] - must be a constructor with .prototype property that is instanceof Error
 * @param {Function} callback
 *
 * @returns {RouteInterface} - self
 */
Route.prototype.catch = function () {
    var lastStep = this.steps[this.steps.length - 1];

    if (!_.isPlainObject(lastStep)) {
        throw new RouteError(
            'Can NOT apply a `catch` error handler middleware at this stage'
        );
    }

    //normalize method arguments
    //bluebird's catch method signature has the following signature:
    //catch(errorFilter, callback)
    //where `errorFilter` is optional argument
    var args = Array.prototype.slice.call(arguments, 0);
    if (args.length < 2) {
        args.unshift(Error);
    }

    lastStep.catch = Array.isArray(lastStep.catch) ? lastStep.catch : [];
    lastStep.catch.push(args);

    return this;
};

/**
 * the Response object can be returned from within route middleware which will
 * cause promise call chain interruption of current request and prioritized response
 *
 * @example
 * route.step(function() {
 *     return route.buildResponse(function() {
 *         this.json({response: 'data'});
 *     }):
 * }).step(function() {
 *     //will never be called
 * });
 *
 * @param {Function} cb - callback function which sets response on the express `res` object. The function's context is always set to the `res` object
 * @return {Response}
 */
Route.prototype.buildResponse = function(cb) {
    return new Response(cb);
};

/**
 * constructs single function which takes req & res & next arguments
 * @private
 * @return {Function}
 */
Route.prototype.build = function() {

    const self = this;
    this.emit('build');

    return function callback(req, res, next) {

        //per request unique context object
        var reqContext = Object.create(Object.prototype, {
            route: {
                writable: false,
                value: self
            },
            app: {
                writable: false,
                value: self.Router.App
            }
        });

        if (!self.steps.length) {
            return Promise.reject(
                new RouteError(`Route ${self.options.url} not implemented`)
            );
        }

        var promise = Promise.resolve();

        self.steps.forEach(function(step) {
            promise = promise.then(function(resCandidate) {
                if (resCandidate instanceof Response) {
                    resCandidate._fn.call(res);
                    //promise cancellation feature must be explicitly enabled beforehand
                    return promise.cancel();
                }
                return step.fn.call(reqContext, req, res);
            });

            ////integrate with route.catch
            promise = applyCatchList(promise, req, res, step.catch);
        }, self);

        //if a callback function is provided and an unhandled error is
        //encountered, redirect it to the callback function.
        if (typeof next === 'function') {
            return promise.catch(function(err) {
                if (!(err instanceof Error)) {
                    let msg = `Received error value of ${typeof err} type`
                        + `, which is not instanceof Error`;
                    err = new Error(msg);
                }
                //we must ensure that err is instanceof Error as if
                //we were to call eg.: next(undefined), express application
                //would try to dispatch another matching route
                return next(err);
            });
        }
        return promise;
    };
};

/**
 * applies collection of catch handler functions to provided Promise object
 *
 * @private
 *
 * @param {Promise} promise - the promise catch functions are going to be applied to
 * @param {Object}  req
 * @param {Object}  res
 * @param {Array}   catchList - array of arrays - each item of array is a pair of [ErrorFilterConstructor,FunctionErrHandler]
 * @return {Promise}
 */
function applyCatchList(promise, req, res, catchList, index) {
    index = index || 0;

    if (   !Array.isArray(catchList)
        || index > catchList.length - 1
        || !Array.isArray(catchList[index])
        || !(catchList[index][1] instanceof Function)
    ) {
        return promise;
    }

    var args = _.clone(catchList[index]);
    var cb = args[1];

    args[1] = function(err) {
        return cb(err, req, res);
    };

    promise = promise.catch.apply(promise, args);
    return applyCatchList(promise, req, res, catchList, ++index);
}

//required by the RouteInterface.prototype.build method
function noop() {}

/**
 * Route#build event listener
 * @private
 */
function pushErrorHandler() {
    let app = this.Router.App;

    //add error handler
    this.catch(function(err, req, res) {
        return errorHandler.errorHandler.call(app, err, req, res)
    });
}

/**
 * Route#build event listener
 * @private
 */
function pushNoop() {
    //no operation middleware is required as it allows post-processing
    //of fulfillment value of the last middleware
    this.step('noop', noop);
}