RackHD/on-http

View on GitHub
lib/services/rest-api-service.js

Summary

Maintainability
D
2 days
Test Coverage
// Copyright 2015, EMC, Inc.

'use strict';

var di = require('di');
var bodyParser = require('body-parser');
var http = require('http');

module.exports = restFactory;

di.annotate(restFactory, new di.Provide('Http.Services.RestApi'));
di.annotate(restFactory,
    new di.Inject(
            'Serializable',
            'Promise',
            '_',
            'Assert',
            'Util',
            'Errors',
            'ErrorEvent',
            'Logger',
            di.Injector
        )
    );

function restFactory(
    Serializable,
    Promise,
    _,
    assert,
    util,
    Errors,
    ErrorEvent,
    Logger,
    injector
) {

    var logger = Logger.initialize(restFactory);

    var NO_CONTENT_STATUS = 204;
    var BAD_REQUEST_STATUS = 400;
    var ERROR_STATUS = 500;

    /**
     * Convenience constructor for HTTP errors. These errors can be thrown or used as a rejected
     * value from any serializer, deserializer or callback to {@link rest}.
     *
     * @param {number} status HTTP status code to send.
     * @param {string} [message] Error text to be presented to the user.
     * @param {*} [context] Additional error metadata.
     */

    function HttpError(status, message, context) {
        HttpError.super_.call(this, message, context);
        this.status = status;
    }

    util.inherits(HttpError, Errors.BaseError);

    /**
     * Middleware factory function for generic REST API services. Currently only supports JSON
     * input/output. There are five steps to the middleware:
     *
     * - parse - Parse JSON input and throw errors if parsing fails
     * - deserialize - Validate and transform the input data
     * - exec - Run the provided callback and capture its output
     * - serialize - Validate and transform the output data
     * - render - Stringify JSON output and write out the request
     *
     * Valid options:
     *
     * - isArray {boolean} - Set to true to have the serializer run as a map over an array of
     *                       objects. This will also cause the serializer to produce an error if
     *                       the callback function does not return an array.
     * - parseOptions {object} - Options to pass to JSON body parser.
     * - renderOptions {object} - Options to pass to render middleware. See {@link rest#render}.
     * - deserializer {function|string} - Deserializer function or injector string of deserializer
     *                                    class. See {@link rest#deserialize} and
     *                                    {@link rest#createDeserializeFunc}.
     * - serializer {function|string} - Serializer function or injector string of serializer class.
     *                                  See {@link rest#serialize} and
     *                                  {@link rest#createSerializeFunc}.
     *
     * @param {function} callback Callback that takes req and res parameters and has its return
     *                            value resolved as a promise. When the promise is resolved or
     *                            rejected, the value will be written out as the response body.
     * @param {object} [options] Options hash.
     * @returns {function[]} Middleware pipeline.
     */

    function rest(callback, options) {
        assert.func(callback, 'callback function is required');
        assert.optionalObject(options, 'options should be an optional object');

        options = options || {};

        var deserializer = options.deserializer;
        if (typeof deserializer === 'string') {
            deserializer = rest.createDeserializeFunc(injector.get(deserializer));
        }

        assert.optionalFunc(
            deserializer,
            'options.deserializer should be an optional function or injector string');

        var serializer = options.serializer;
        if (typeof serializer === 'string') {
            serializer = rest.createSerializeFunc(injector.get(serializer));
        }

        assert.optionalFunc(
            serializer,
            'options.serializer should be an optional function or injector string');

        var middleware = [
            rest.parse(options.parseOptions),
            rest.deserialize(deserializer),
            rest.exec(callback),
            options.isArray ?
                rest.serializeArray(serializer) :
                rest.serialize(serializer),
            rest.render(options.renderOptions),
            rest.handleError()
        ];

        return middleware;
    }

    /**
     * Parses JSON from the request body into req.body. Uses body-parser internally.
     * @param {object} options Options hash. Passed directly to body parser.json().
     * @returns {function} Middleware function.
     */

    rest.parse = function (options) {
        assert.optionalObject(options, 'options should be an optional object');
        options = options || {};

        var parser = bodyParser.json(options);
        return function parserMiddleware(req, res, next) {
            if (req.method === 'POST' || req.method === 'PATCH' || req.method === 'PUT') {
                var contentLength = parseInt(req.headers['content-length'], 10);

                if (contentLength) {
                    if (!req.is('json')) {
                        next(new HttpError(BAD_REQUEST_STATUS,
                                           'Content-Type must be application/json'));
                                           return;
                    }
                    parser(req, req, function (err) {
                        if (err) {
                            err.message = 'Error parsing JSON: ' + err.message;
                        }
                        next(err);
                    });
                    return;
                }
            }
            next();
        };
    };

    /**
     * Takes a promise-returning callback and returns a middleware function that calls next() when
     * the promise resolves.
     * @param {function} callback Callback that takes req and res parameters and has its return
     *                            value resolved as a promise. When the promise is resolved or
     *                            rejected, the next() function will be called.
     * @returns {function} Middleware function.
     */

    rest.async = function (callback) {
        assert.func(callback, 'callback should be a function');
        return function asyncMiddleware(req, res, next) {
            Promise.resolve().then(function () {
                return callback(req, res);
            }).then(function () {
                next();
            }).catch(function (err) {
                next(err || new Error());
            });
        };
    };

    /**
     * Runs a deserializer function on req.body.
     * @param {function} deserializer Callback that takes an object to be deserialized and returns
     *                                the deserialized value, or a promise resolved to the
     *                                deserialized value.
     * @returns {function} Middleware function.
     */

    rest.deserialize = function (deserializer) {
        assert.optionalFunc(deserializer, 'deserializer should be an optional function');
        return rest.async(function deserializeMiddleware(req) {
            if (deserializer) {
                var options = {
                    method: req.method
                };
                return Promise.resolve(deserializer(req.body, options))
                .then(function (value) {
                    req.originalBody = req.body;
                    req.body = value;
                }).catch(function (err) {
                    // Need to check to string here since we wrap errors with new Error() to support
                    // util.isError checks by swagger going forward.
                    if (err && err.toString().includes(Errors.ValidationError.name)) {
                        err.status = BAD_REQUEST_STATUS;
                    } else if (err && err.toString().includes(Errors.SchemaError.name)) {
                        err.status = BAD_REQUEST_STATUS;
                    }
                    throw err;
                });
            }
        });
    };

    /**
     * Takes a promise-returning callback and sets the resolved value to res.body, then calls
     * next().
     * @param {function} callback Callback that takes req and res parameters and returns the value
     *                            to output in the response body, or a promise resolved to the
     *                            desired output.
     * @returns {function} Middleware function.
     */

    rest.exec = function (callback) {
        assert.func(callback, 'callback should be a function');
        return rest.async(function execMiddleware(req, res) {
            return Promise.resolve(callback(req, res))
            .then(function (value) {
                res.body = value;
            });
        });
    };

    /**
     * Runs a serializer function on res.body.
     * @param {function} serializer Callback that takes an object to be serialized and returns
     *                              the serialized value, or a promise resolved to the serialized
     *                              value.
     * @returns {function} Middleware function.
     */

    rest.serialize = function (serializer) {
        assert.optionalFunc(serializer, 'serializer should be an optional function');
        return rest.async(function serializeMiddleware(req, res) {
            if (serializer) {
                return Promise.resolve(serializer(res.body))
                .then(function (value) {
                    res.originalBody = res.body;
                    res.body = value;
                });
            }
            return res.body;
        });
    };

    /**
     * Checks that res.body is an array, then runs a serializer function on each element.
     * @param {function} serializer Callback that takes an object to be serialized and returns
     *                              the serialized value, or a promise resolved to the serialized
     *                              value.
     * @returns {function} Middleware function.
     */

    rest.serializeArray = function (serializer) {
        return rest.serialize(function (data) {
            assert.ok(Array.isArray(data), 'output should be an array');
            if (serializer) {
                return Promise.all(_.map(data, function (record) {
                    return serializer(record);
                }));
            }
            return data;
        });
    };

    /**
     * Writes out the content of res.body as JSON. Valid options:
     *
     * - success [integer] - Response code to send on success.
     *
     * @param {object} options Options hash.
     * @returns {function} Middleware function.
     */

    rest.render = function (options) {
        assert.optionalObject(options, 'options should be an optional object');
        options = options || {};

        assert.optionalNumber(options.success, 'options.success should be an optional number');
        if (options.success) {
            assert.isIn(options.success.toString(), _.keys(http.STATUS_CODES));
        }

        return function renderMiddleware(req, res, next) {
            if (!res.headersSent) {
                if (res.body === null || res.body === undefined) {
                    res.status(NO_CONTENT_STATUS);
                    res.end();
                } else {
                    if (options.success) {
                        res.status(options.success);
                    }
                    res.json(res.body);
                }
            }
            next();
        };
    };

    /**
     * Catches errors and writes them out as JSON.
     * @returns {function} Middleware function.
     */

    rest.handleError = function () {
        return function handleErrorMiddleware(err, req, res, next) {
            // TODO - implement custom error type serializers
            if (err instanceof Error || err instanceof ErrorEvent) {
                var message = err.message || http.STATUS_CODES[err.status] || 'Unspecified Error';
                logger.error(message, {
                    error: err,
                    path: req.path
                });

                if (!err.status || err.status === ERROR_STATUS) {
                    res.status(ERROR_STATUS);
                    res.json({
                        message: message
                    });
                } else {
                    res.status(err.status);
                    res.json({
                        message: message
                    });
                }
            } else if (typeof err === 'string') {
                res.status(ERROR_STATUS);
                res.json({
                    message: err
                });
            } else {
                res.status(ERROR_STATUS);
                res.json(err);
            }

            next(err);
        };
    };

    /**
     * Takes a deserializer class and turns it into a promise returning function. The deserializer
     * class must be new-able and must have validate and deserialize methods on its prototype. These
     * methods should both take one argument (the object to transform) and should return a
     * value or a promise. The result from the deserialize function will be used as the final
     * resolved value.
     * @param {Deserializer} Deserializer Deserializer constructor.
     * @returns {function} Promise-returning deserializer function.
     */

    rest.createDeserializeFunc = function (Deserializer) {
        assert.func(Deserializer, 'deserializer should be a class');
        assert.ok(Deserializer.prototype instanceof Serializable,
                 'Deserializer is not an instance of Serializable');

        return function (data, options) {
            var deserializer = new Deserializer();
            return Promise.resolve().then(function () {
                if (options.method === 'PATCH') {
                    return deserializer.validatePartial(data);
                }
                return deserializer.validate(data);
            }).then(function () {
                return deserializer.deserialize(data);
            });
        };
    };

    /**
     * Takes a serializer class and turns it into a promise returning function. The serializer
     * class must be new-able and must have validate and serialize methods on its prototype. These
     * methods should both take one argument (the object to transform) and should return a
     * value or a promise. The result from the serialize function will be used as the final
     * resolved value.
     * @param {Serializer} Serializer Serializer constructor.
     * @returns {function} Promise-returning deserializer function.
     */

    rest.createSerializeFunc = function (Serializer) {
        assert.func(Serializer, 'serializer should be a class');

        return function (data) {
            var serializer = new Serializer();

            assert.ok(serializer instanceof Serializable,
                'serializer is not an instance of Serializable');

            return Promise.resolve().then(function () {
                return serializer.serialize(data);
            }).then(function (serialized) {
                return serializer.validateAsModel(serialized).then(function () {
                    return serialized;
                });
            });
        };
    };

    rest.HttpError = HttpError;

    return rest;
}