backstubber.js

/**
* @author {@link https://github.com/bcluca|Luca Bernardo Ciddio}
* @license MIT
*/
'use strict';

var express    = require('express');
var bodyParser = require('body-parser');
var glob       = require('glob');
var path       = require('path');
var utils      = require('./utils');
var handlers   = require('./handlers');

var VERBS = ['head', 'get', 'post', 'put', 'delete', 'all'];
var STATUS_REGEX = /^[1-5](\d|[xX]){2}$/;

/**
* Creates a new {@link module:backstubber~Backstubber|Backstubber} object.
* @private
* @constructor module:backstubber~Backstubber
* @returns {Backstubber} A new {@link module:backstubber~Backstubber|Backstubber} instance.
*/
function Backstubber() {
    var app = express();
    var stubsByStatus = {};

    app.use(bodyParser.urlencoded({ extended: true }));
    app.use(bodyParser.json());

    this._app = app;

    this._registerStub = function (stub, format, verb, endpoint, service, status) {
        if (!service) {
            app[verb](endpoint, handlers.stubHandler(stub, format));
        } else {
            if (!stubsByStatus[endpoint]) {
                var stubs = [];
                stubsByStatus[endpoint] = stubs;
                app[verb](endpoint, handlers.statusHandler(service, stubs));
            }
            stubsByStatus[endpoint].push({
                pattern : utils.regexForStatus(status || 'xxx'),
                stub    : stub,
                format  : format
            });
        }
        return this;
    };
}

/**
* Proxies all unstubbed calls to an external service.
* @function module:backstubber~Backstubber#proxy
* @param {string} path - Endpoint path, usually set to `*` to proxy all unhandled routes.
* @param {string} service - External service URL.
* @returns {Backstubber} The current {@link module:backstubber~Backstubber|Backstubber} instance, for chaining.
*/
Backstubber.prototype.proxy = function (path, service) {
    utils.requiredArgs('path', 'service', arguments);
    this._app.all(path, handlers.proxyHandler(service));
    return this;
};

/**
* Mounts a directory containing stub definitions. Also provides optional merging with the response from an external service.
* @function module:backstubber~Backstubber#mount
* @param {string} dir - Directory where the stubs are defined.
* @param {string} [service] - External service URL.
* @returns {Backstubber} The current {@link module:backstubber~Backstubber|Backstubber} instance, for chaining.
*/
Backstubber.prototype.mount = function (dir, service) {
    utils.requiredArgs('dir', arguments);

    if (dir === '*') {
        // For backward compatibility with v0.1.9 and below
        this.proxy('*', service);
        return this;
    }

    var self = this;
    glob.sync(dir + '/**/*.@(json|js)').forEach(function (filePath) {
        var ext    = path.extname(filePath);
        var name   = path.basename(filePath, ext);
        var vs     = name.split('.');
        var verb   = vs[0];
        var status = vs[1];

        if (VERBS.indexOf(verb) === -1) {
            throw new Error("unknown verb '" + verb + "'");
        }
        if (status && !service) {
            throw new Error("'service' is required for status code specific stubs");
        }
        if (status && !STATUS_REGEX.test(status)) {
            throw new Error("'" + status + "' is not a valid HTTP status code");
        }

        var relativePath = path.relative(dir, filePath);
        var relativeDir  = path.dirname(relativePath);
        var endpoint     = '/';
        var stub         = require(filePath);
        var format       = ext.substr(1);

        if (relativeDir !== '.') {
            endpoint += relativeDir;
        }

        self._registerStub(stub, format, verb, endpoint, service, status);
    });

    return this;
};

function stubVerbFn(verb) {
    return function (route, stub, service, status) {
        return this._registerStub(stub, 'js', verb, route, service, status);
    };
}

/**
* Stubs an individual `HEAD` route, optionally merging with the response from an external service.
* If a status code is passed, this will only stub calls that receive a matching status code from the service.
* @function module:backstubber~Backstubber#head
* @param {string} route - Endpoint route.
* @param {object} stub - Stub definition.
* @param {string} [service] - Service URL.
* @param {number|string} [status] - HTTP status code, e.g. 200, 301, 3xx.
* @returns {Backstubber} The current {@link module:backstubber~Backstubber|Backstubber} instance, for chaining.
*/
Backstubber.prototype.head = stubVerbFn('head');

/**
* Stubs an individual `GET` route, optionally merging with the response from an external service.
* If a status code is passed, this will only stub calls that receive a matching status code from the service.
* @function module:backstubber~Backstubber#get
* @param {string} route - Endpoint route.
* @param {object} stub - Stub definition.
* @param {string} [service] - Service URL.
* @param {number|string} [status] - HTTP status code, e.g. 200, 301, 3xx.
* @returns {Backstubber} The current {@link module:backstubber~Backstubber|Backstubber} instance, for chaining.
*/
Backstubber.prototype.get = stubVerbFn('get');

/**
* Stubs an individual `POST` route, optionally merging with the response from an external service.
* If a status code is passed, this will only stub calls that receive a matching status code from the service.
* @function module:backstubber~Backstubber#post
* @param {string} route - Endpoint route.
* @param {object} stub - Stub definition.
* @param {string} [service] - Service URL.
* @param {number|string} [status] - HTTP status code, e.g. 200, 301, 3xx.
* @returns {Backstubber} The current {@link module:backstubber~Backstubber|Backstubber} instance, for chaining.
*/
Backstubber.prototype.post = stubVerbFn('post');

/**
* Stubs an individual `PUT` route, optionally merging with the response from an external service.
* If a status code is passed, this will only stub calls that receive a matching status code from the service.
* @function module:backstubber~Backstubber#put
* @param {string} route - Endpoint route.
* @param {object} stub - Stub definition.
* @param {string} [service] - Service URL.
* @param {number|string} [status] - HTTP status code, e.g. 200, 301, 3xx.
* @returns {Backstubber} The current {@link module:backstubber~Backstubber|Backstubber} instance, for chaining.
*/
Backstubber.prototype.put = stubVerbFn('put');

/**
* Stubs an individual `DELETE` route, optionally merging with the response from an external service.
* If a status code is passed, this will only stub calls that receive a matching status code from the service.
* @function module:backstubber~Backstubber#delete
* @param {string} route - Endpoint route.
* @param {object} stub - Stub definition.
* @param {string} [service] - Service URL.
* @param {number|string} [status] - HTTP status code, e.g. 200, 301, 3xx.
* @returns {Backstubber} The current {@link module:backstubber~Backstubber|Backstubber} instance, for chaining.
*/
Backstubber.prototype.delete = stubVerbFn('delete');

/**
* Stubs an individual route for all HTTP verbs, optionally merging with the response from an external service.
* If a status code is passed, this will only stub calls that receive a matching status code from the service.
* @function module:backstubber~Backstubber#all
* @param {string} route - Endpoint route.
* @param {object} stub - Stub definition.
* @param {string} [service] - Service URL.
* @param {number|string} [status] - HTTP status code, e.g. 200, 301, 3xx.
* @returns {Backstubber} The current {@link module:backstubber~Backstubber|Backstubber} instance, for chaining.
*/
Backstubber.prototype.all = stubVerbFn('all');

/**
* Binds and listens for connections on the specified host and port. This method is identical to Node’s
* {@link https://nodejs.org/api/http.html#http_server_listen_port_hostname_backlog_callback|http.Server.listen()}.
* @function module:backstubber~Backstubber#listen
* @param {number} port - The port this service will listen on.
* @param {string} [hostname=Any IPv4 address.] - The service hostname.
* @param {number} [backlog=511] - The maximum length of the queue of pending connections.
* @param {function} [callback] - Callback function to be called when this server starts listening.
* @returns {Backstubber} The current {@link module:backstubber~Backstubber|Backstubber} instance, for chaining.
*/
Backstubber.prototype.listen = function () {
    this._app.listen.apply(this._app, arguments);
    return this;
};

module.exports = Backstubber;