Big progress commit

This commit is contained in:
Timothy Warren 2016-02-18 21:50:45 -05:00
parent 5fecd50e7c
commit a37aaf0576
35 changed files with 990 additions and 339 deletions

123
app/Container.js Normal file
View File

@ -0,0 +1,123 @@
'use strict';
const errors = require('errors');
const express = require('express');
const path = require('path');
const autoLoad = require('./config/container-autoload');
/**
* Determine the appropriate path to a module relative to the 'app' folder
*
* @private
* @param {string} modulePath - the raw path to the module
* @return {string} - the normalized path to the module
*/
function normalizeIncludePath(modulePath) {
const basePath = path.resolve(path.join(__dirname, '../'));
let includePath = modulePath;
// Allow referencing local modules without using a ./
// eg. util/route-loader instead of ./util/route-loader
if (
modulePath.includes('/') &&
! (modulePath.startsWith('./') || modulePath.includes(__dirname))
) {
includePath = path.join(__dirname, modulePath);
}
return includePath;
}
/**
* Container for keeping track of dependencies
*/
class Container {
constructor() {
const app = express();
let container = new Map();
// Save the base app object
container.set('app', app);
// Preload some configured modules
autoLoad.map((module) => {
let moduleMap = module;
// Normalize config into [key, value]
if (! Array.isArray(module)) {
moduleMap = [module, module];
}
// Actually require the module
moduleMap[1] = require(normalizeIncludePath(moduleMap[1]));
container.set.apply(container, moduleMap);
});
this._container = container;
}
/**
* Determine if an item exists in the container
*
* @param {string} name - name of the item
* @return {boolean} - whether the item exists in the container
*/
has(name) {
return this._container.has(name);
}
/**
* Return an existing object instance
*
* @param {string} name - name of the item
* @return {Object|undefined} - the item, or undefined if it doesn't exist
*/
get(name) {
if (this.has(name)) {
return this._container.get(name);
}
try {
return this._require(name);
} catch (e) {
return;
}
}
/**
* Set an object in the container
*
* @param {string} name - name to associate with object
* @param {Object} object - item to keep track of
* @return {Container} - the container instance
*/
set(name, object) {
this._container.set(name, object);
return this;
}
/**
* Does a native require, relative to the lib folder,
* and returns the value
*
* @private
* @param {string} modulePath - name of the module to require
* @return {*} - the value returned from require
*/
_require(modulePath) {
// If the value is already saved, just return it
if (this.has(modulePath)) {
return this.get(modulePath);
}
// Save the item for later
let item = require(normalizeIncludePath(modulePath));
this.set(modulePath, item);
return item;
}
}
module.exports = new Container();

104
app/base/ApiModel.js Normal file
View File

@ -0,0 +1,104 @@
'use strict';
const container = require('../Container'),
axios = require('axios');
const Model = container.get('base/Model');
/**
* Base class for API Models
*
* Wraps convenience methods for making API requests.
*
* @param {string} [baseUrl] - a base url for api requests
* @extends Model
*/
class ApiModel extends Model {
/**
* Create a new APIModel
*
* @constructor
* @param {string} [baseUrl] - a base url for api requests
*/
constructor(baseUrl) {
super();
let apiConfig = {};
if (baseUrl) {
apiConfig.baseUrl = baseUrl;
}
this.client = axios(apiConfig);
}
/**
* Make a 'HEAD' HTTP request
*
* @param {string} url - the url to request
* @param {Object} [config] - options for the current request
* @return {Promise} - promise wrapping the request
*/
head(url, config) {
return this.client.head(url, config);
}
/**
* Make a 'GET' HTTP request
*
* @param {string} url - the url to request
* @param {Object} [config] - options for the current request
* @return {Promise} - promise wrapping the request
*/
get(url, config) {
return this.client.get(url, config);
}
/**
* Make a 'DELETE' HTTP request
*
* @param {string} url - the url to request
* @param {Object} [config] - options for the current request
* @return {Promise} - promise wrapping the request
*/
delete(url, config) {
return this.client.delete(url, config);
}
/**
* Make a 'POST' HTTP request
*
* @param {string} url - the url to request
* @param {Object} data - the data for the current request
* @param {Object} [config] - options for the current request
* @return {Promise} - promise wrapping the request
*/
post(url, data, config) {
return this.client.post(url, data, config);
}
/**
* Make a 'PUT' HTTP request
*
* @param {string} url - the url to request
* @param {Object} data - the data for the current request
* @param {Object} [config] - options for the current request
* @return {Promise} - promise wrapping the request
*/
put(url, data, config) {
return this.client.put(url, data, config);
}
/**
* Make a 'PATCH' HTTP request
*
* @param {string} url - the url to request
* @param {Object} data - the data for the current request
* @param {Object} [config] - options for the current request
* @return {Promise} - promise wrapping the request
*/
patch(url, data, config) {
return this.client.patch(url, data, config);
}
}
module.exports = ApiModel;

182
app/base/Config.js Normal file
View File

@ -0,0 +1,182 @@
'use strict';
const container = require('../Container');
const path = container.get('path');
// Load environment file
require('dotenv').config({
path: path.resolve(__dirname, '../../.env'),
});
const fs = require('fs');
const glob = require('glob');
const configSslPath = path.resolve(__dirname, '../config/ssl');
const configPath = path.resolve(__dirname, '../config');
const defaultConfig = new Map([
['host', 'localhost'],
['http', false],
['http-port', 80],
['https', true],
['https-port', 443],
['https-config-cert', path.join(configSslPath, 'localhost.crt')],
['https-config-key', path.join(configSslPath, 'localhost.key')],
['node-env', 'development'],
]);
const configFiles = glob.sync(`${configPath}/**/*.js`);
/**
* Config management class
*
* Hierarchy of config options
* 1. Directly defined config options
* 2. Environment variables
* 3. Default values
*/
class Config {
constructor() {
let _configMap = new Map();
// Load files in config folder under their
// own respective namespaces
configFiles.forEach((fullPath) => {
let key = path.basename(fullPath, '.js');
_configMap.set(key, require(fullPath));
});
this._config = _configMap;
}
/**
* Returns the list of available configuration keys
*
* @return {array} - the list of configuration keys
*/
keys() {
let keys = [];
for (let key of this._config.keys()) {
keys.push(key);
}
return keys;
}
/**
* Determine if a config value exists
*
* @param {string|symbol} key - the config value key
* @return {boolean} - whether the config value exists
*/
has(key) {
return this._config.has(key);
}
/**
* Determine whether an environment variable is currently defined
*
* @param {string} key - the environment varaible to look for
* @return {boolean} - whether the environment variable is defined
*/
hasEnv(key) {
return process.env[this._envName(key)] != null;
}
/**
* Retrieve a config value
*
* @param {string|symbol} key - the name of the config value
* @return {*} - the configuration value
*/
get(key) {
if (! this.has(key)) {
// Fallback to environment variables
let envKey = key.toUpperCase().replace('-', '_');
if (this.hasEnv(envKey)) {
let envValue = this.getEnv(envKey);
this._config.set(key, envValue);
return envValue;
}
// Fallback to default values
if (defaultConfig.has(key)) {
let defaultValue = defaultConfig.get(key);
this._config.set(key, defaultValue);
return defaultValue;
}
}
return this._config.get(key);
}
/**
* Get the value of an environment variable
*
* @param {string} key - the environment variable to get
* @return {string|undefined} - the value of the environment variable
*/
getEnv(key) {
let raw = process.env[this._envName(key)];
return this._normalizeValue(raw);
}
/**
* Get equivalent environment variable for config key
*
* @private
* @param {string} key - the config key
* @return {string} - the environment variable name
*/
_envName(key) {
return key.toUpperCase().replace('-', '_');
}
/**
* Attempt to parse javascript types from strings
*
* @private
* @param {string} val - the string value
* @return {string|number|boolean} - the 'parsed' value
*/
_normalizeValue(val) {
let bool = /^true|false$/;
let integer = /^([0-9]+)$/;
if (String(val).search(integer) !== -1) {
return parseInt(val, 10);
}
if (String(val).search(bool) !== -1) {
switch (val) {
case 'true':
return true;
// return overrides break
case 'false':
return false;
// return overrides break
}
}
return val;
}
/**
* Set a config variable (mainly for testing)
*
* @private
* @param {mixed} key - the key to set
* @param {mixed} val - the value for the key
* @return {Config} - the config instance
*/
_set(key, val) {
this._config.set(key, val);
return this;
}
}
module.exports = new Config();

74
app/base/Controller.js Normal file
View File

@ -0,0 +1,74 @@
'use strict';
const container = require('../Container');
const express = require('express');
const errors = require('errors');
const getArgs = require('getargs');
const loadRoutes = container.get('base/util/route-loader');
const _ = container.get('_');
/**
* Controller setup and utility class
*/
class Controller {
/**
* Get route mapping
*
* @param {string} baseRoutePath - the path to the folder containing
* controllers/routes
* @return {map} - Maps route prefixes to their respective functions
*/
static getRouteMap(baseRoutePath) {
const routeMap = {};
const pathFileMap = loadRoutes(baseRoutePath);
_(pathFileMap).forEach((routeFile, routePath) => {
let rawRoutes = require(routeFile);
routeMap[routePath] = Controller._parseRoutes(rawRoutes);
});
return routeMap;
}
/**
* Simplify route generation with an object
*
* @private
* @param {Object} routeObject - the object laying out the routes for the
* current controller
* @return {Router} - the updated express.Router object
*/
static _parseRoutes(routeObject) {
const router = express.Router();
_(routeObject).forEach((httpMethods, currentPath) => {
_(httpMethods).forEach((routeFunction, currentHttpMethod) => {
let routerMethod = router[currentHttpMethod];
return router[currentHttpMethod](currentPath, routeFunction);
});
});
return router;
}
/**
* Send a specific http error code
*
* @param {number} code - The error Number
* @param {Object} [options={}] - Options, such as message and explanation
* @param {function} next - The callback to pass the error to
* @return {void}
*/
static HttpError(/*code, options, next*/) {
let args = getArgs('code:Number, [options]:object, next:function', arguments);
args.options = args.options || {};
let methodName = `Http${args.code}Error`;
let err = new errors[methodName](args.options);
return args.next(err);
}
}
module.exports = Controller;

33
app/base/HttpServer.js Normal file
View File

@ -0,0 +1,33 @@
'use strict';
const http = require('http'),
logger = require('winston');
const Server = require('./Server');
/**
* Class for creating an http server
*
* @extends Server
* @param {Express} app - current express instance
* @param {number} port - the port to listen on
*/
class HttpServer extends Server {
constructor(app, port) {
super();
let server = http.createServer(app);
server.listen(port);
server.on('error', this.onError);
server.on('listening', () => {
let addr = server.address();
let bind = typeof addr === 'string'
? `pipe ${addr}`
: `port ${addr.port}`;
logger.info(`Listening on ${bind}`);
});
this.server = server;
}
}
module.exports = HttpServer;

34
app/base/HttpsServer.js Normal file
View File

@ -0,0 +1,34 @@
'use strict';
const https = require('https'),
logger = require('winston');
const Server = require('./Server');
/**
* Class for creating an https server
*
* @extends Server
* @param {Express} app - current express instance
* @param {number} port - the port to listen on
* @param {Object} options - https server options
*/
class HttpsServer extends Server {
constructor(app, port, options) {
super();
let server = https.createServer(options, app);
server.listen(port);
server.on('error', this.onError);
server.on('listening', () => {
let addr = server.address();
let bind = typeof addr === 'string'
? `pipe ${addr}`
: `port ${addr.port}`;
logger.info(`Listening on ${bind}`);
});
this.server = server;
}
}
module.exports = HttpsServer;

View File

@ -1,5 +1,8 @@
'use strict'; 'use strict';
/**
* Base Model class
*/
class Model { class Model {
} }

42
app/base/Server.js Normal file
View File

@ -0,0 +1,42 @@
'use strict';
/**
* Base Class for wrapping HTTP/HTTPS servers
*/
class Server {
constructor() {}
/**
* Event listener for HTTP(s) server "error" event.
*
* @param {error} error - the error object
* @return {null} - Does not return a value
* @throws {error}
*/
onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
let port = this.server.address().port;
let bind = typeof port === 'string'
? `Pipe ${port}`
: `Port ${port}`;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
logger.error(`${bind} requires elevated privileges`);
process.exit(1);
break;
case 'EADDRINUSE':
logger.error(`${bind} is already in use`);
process.exit(1);
break;
default:
throw error;
}
}
}
module.exports = Server;

View File

@ -5,15 +5,16 @@ const glob = require('glob');
/** /**
* Map Routes to route files * Map Routes to route files
* *
* @param {String} path - folder with Routes * @param {string} path - folder with Routes
* @return {Object} - Object mapping routes to their files * @return {Object} - Object mapping routes to their files
*/ */
module.exports = function routeLoader(path) { module.exports = function routeLoader(path) {
const basePath = path; const basePath = path.replace(/\\/g, '/');
let paths = glob.sync(`${path}/**/*.js`); let paths = glob.sync(`${path}/**/*.js`);
paths = paths.sort(); paths = paths.sort();
paths = paths.map((path) => path.replace('\\', '/'));
let routes = {}; let routes = {};

27
app/bootstrap.js vendored Normal file
View File

@ -0,0 +1,27 @@
'use strict';
const container = require('./Container');
const _ = container.get('_');
const app = container.get('app');
const path = container.get('path');
const Controller = container.get('base/Controller');
const Config = container.get('base/Config');
module.exports = (function () {
const baseRoutePath = path.join(__dirname, 'controllers');
// load middleware
Config.get('middleware').forEach((mw) => app.use(mw));
// automatically set up routing by folder structure
let routeMap = Controller.getRouteMap(baseRoutePath);
_(routeMap).forEach((routeFunction, routePrefix) => {
app.use(routePrefix, routeFunction);
});
// load error handlers
Config.get('error-handlers').forEach((handler) => app.use(handler));
return app;
}());

View File

@ -0,0 +1,15 @@
'use strict';
/**
* A list of modules to insert into the Container at start up
*
* Modules to be loaded with an alias should have an array with the alias
* then the module name. Otherwise, just a string with the module name
*
* @type {array}
*/
module.exports = [
['_', 'lodash'],
'path',
['promisify', 'helpers/promisify'],
];

View File

@ -6,23 +6,29 @@
const container = require('../Container'); const container = require('../Container');
const app = container.get('app'); const app = container.get('app');
const HTTP_CODE_MAP = require('http').STATUS_CODES;
const errors = require('errors');
let errorHandlers = [ let errorHandlers = new Set([
// catch 404 and forward to error handler function handle404(req, res, next) {
(req, res, next) => { // if no route matches, send a 404
let err = new Error('Not Found'); if (! req.route) {
err.status = 404; let err = new errors.Http404Error();
next(err); return next(err);
}
}, },
// general error handler // general error handler
(err, req, res, next) => { function handleError(err, req, res, next) {
res.status(err.status || 500); let httpStatus = err.status || 500;
let message = err.message || HTTP_CODE_MAP[httpStatus];
res.status(httpStatus);
let output = { let output = {
status: err.status, status: httpStatus,
message: err.message, message: message,
}; };
// Show stack trace in development environment // Show stack trace in development environment
@ -32,6 +38,6 @@ let errorHandlers = [
res.json(output); res.json(output);
}, },
]; ]);
module.exports = errorHandlers; module.exports = errorHandlers;

View File

@ -11,7 +11,7 @@ const bodyParser = require('body-parser'),
requestLogger = require('morgan'), requestLogger = require('morgan'),
path = require('path'); path = require('path');
let middleware = [ let middleware = new Set([
// some security settings controlled by helmet // some security settings controlled by helmet
helmet.frameguard(), helmet.frameguard(),
@ -26,6 +26,6 @@ let middleware = [
bodyParser.urlencoded({ extended: false }), bodyParser.urlencoded({ extended: false }),
cookieParser(), cookieParser(),
express.static(path.join(__dirname, '../../public')), express.static(path.join(__dirname, '../../public')),
]; ]);
module.exports = middleware; module.exports = middleware;

20
app/controllers/index.js Normal file
View File

@ -0,0 +1,20 @@
'use strict';
module.exports = {
'/': {
// Get homepage
get: (req, res) => {
return res.json({
status: 200,
data: {
index: { title: 'Express' },
},
});
},
put: (req, res, next) => {
return next();
},
},
};

26
app/helpers/promisify.js Normal file
View File

@ -0,0 +1,26 @@
'use strict';
/*eslint-disable prefer-arrow-callback*/
/**
* Function to convert a callback function into a promise
*
* @see http://eddmann.com/posts/promisifying-error-first-asynchronous-callbacks-in-javascript/
* @example promisify(fs.readFile)('hello.txt', 'utf8')
* .then(console.log)
* .catch(console.error)
* @param {Function} fn - the callback function to convert
* @return {Promise} - the new promise
*/
function promisify(fn) {
return function () {
let args = [].slice.call(arguments);
return new Promise(function (resolve, reject) {
fn.apply(undefined, args.concat((error, value) => {
return error ? reject(error) : resolve(value);
}));
});
};
}
module.exports = promisify;
/*eslint-enable prefer-arrow-callback*/

View File

@ -13,7 +13,14 @@ const apidoc = require('gulp-apidoc'),
/* /*
* Path(s) to all source files * Path(s) to all source files
*/ */
const SRC_FILES = ['lib/**/*.js']; const SRC_FILES = [
'app/base/**/*.js',
'app/config/**/*.js',
'app/controllers/**/*.js',
'app/helpers/**/*.js',
'app/models/**/*.js',
'app/*.js',
];
/* /*
* Path to unit test files * Path to unit test files
@ -23,7 +30,7 @@ const UNIT_TEST_FILES = ['test/unit/**/*_test.js'];
/* /*
* Path to integration test files * Path to integration test files
*/ */
const INTEGRATION_TEST_FILES = ['test/end-to-end/**/*_test.js']; const INTEGRATION_TEST_FILES = ['test/integration/**/*_test.js'];
/* /*
* Path(s) to all test files * Path(s) to all test files
@ -76,9 +83,23 @@ const ESLINT_SETTINGS = {
'callback-return': [1], // return when invoking a callback 'callback-return': [1], // return when invoking a callback
'object-shorthand': [1, 'methods'], // Prefer shorthand for functions in object literals/classes, but avoid property shorthand 'object-shorthand': [1, 'methods'], // Prefer shorthand for functions in object literals/classes, but avoid property shorthand
'prefer-template': [1], // Prefer template strings eg. `Hello ${name}`, to string concatenation 'prefer-template': [1], // Prefer template strings eg. `Hello ${name}`, to string concatenation
'no-case-declarations': [2], // Don't define variables in switch labels
'no-const-assign': [2], // Highlight instances where assigning to const declaration
'no-new-symbol': [2], // Symbol is not a constructor, don't use the new keyword
'no-unused-labels': [2], // Error on labels in code that aren't used
}, },
}; };
/*
* Configuration values for mocha
*/
const MOCHA_SETTINGS = {
ui: 'tdd',
bail: true,
slow: 1000,
timeout: 5000,
};
/* /*
* Check syntax and style of test/miscellaneous files * Check syntax and style of test/miscellaneous files
*/ */
@ -143,7 +164,7 @@ gulp.task('src-docs', () => {
*/ */
gulp.task('api-docs', (done) => { gulp.task('api-docs', (done) => {
apidoc({ apidoc({
src: 'lib/', src: 'app/',
dest: 'public/api-docs/', dest: 'public/api-docs/',
}, done); }, done);
}); });
@ -153,18 +174,28 @@ gulp.task('api-docs', (done) => {
*/ */
gulp.task('docs', ['src-docs', 'api-docs']); gulp.task('docs', ['src-docs', 'api-docs']);
/*
* Run integration tests
*/
gulp.task('integration-test', ['lint'], () => {
return gulp.src(INTEGRATION_TEST_FILES)
.pipe(mocha(MOCHA_SETTINGS));
});
/*
* Run unit tests
*/
gulp.task('unit-test', ['lint'], () => {
return gulp.src(UNIT_TEST_FILES)
.pipe(mocha(MOCHA_SETTINGS));
});
/* /*
* Run all tests * Run all tests
*/ */
gulp.task('test', ['lint'], () => { gulp.task('test', ['lint'], () => {
return pipe(gulp.src(TEST_FILES), [ return gulp.src(TEST_FILES)
mocha({ .pipe(mocha(MOCHA_SETTINGS));
ui: 'tdd',
bail: true,
slow: 1000,
timeout: 5000,
}),
]);
}); });
/* /*
@ -180,16 +211,11 @@ gulp.task('pre-coverage', () => {
/* /*
* Run unit tests and generate code coverage * Run unit tests and generate code coverage
* *
* Does not run end-to-end tests * Does not run integration tests
*/ */
gulp.task('coverage', ['lint', 'pre-coverage'], () => { gulp.task('coverage', ['lint', 'pre-coverage'], () => {
return pipe(gulp.src(UNIT_TEST_FILES), [ return pipe(gulp.src(UNIT_TEST_FILES), [
mocha({ mocha(MOCHA_SETTINGS),
ui: 'tdd',
bail: true,
slow: 1000,
timeout: 5000,
}),
istanbul.writeReports({ istanbul.writeReports({
dir: 'public/coverage', dir: 'public/coverage',
reporters:['lcov', 'lcovonly', 'html', 'text'], reporters:['lcov', 'lcovonly', 'html', 'text'],

View File

@ -1,96 +0,0 @@
'use strict';
const express = require('express'),
path = require('path');
/**
* Container for keeping track of dependencies
*/
class Container {
/**
* Create the object container
*
* @constructor
*/
constructor() {
const app = express();
this._container = {
app: app,
};
}
/**
* Determine if an item exists in the container
*
* @param {String} name - name of the item
* @return {Boolean} - whether the item exists in the container
*/
has(name) {
return (name in this._container);
}
/**
* Return an existing object instance
*
* @param {String} name - name of the item
* @return {Object|Null} - the item, or null if it doesn't exist
*/
get(name) {
if (! this.has(name)) {
return null;
}
return this._container[name];
}
/**
* Set an object in the container
*
* @param {String} name - name to associate with object
* @param {Object} object - item to keep track of
* @return {Container} - the container instance
*/
set(name, object) {
this._container[name] = object;
return this;
}
/**
* Does a native require, relative to the lib folder,
* and returns the value
*
* @param {String} modulePath - name of the module to require
* @return {mixed} - the value returned from require
*/
require(modulePath) {
// If the value is already saved, just return it
if (this.has(modulePath)) {
return this.get(modulePath);
}
let includeName = modulePath;
// Allow referencing local modules without using a ./
// eg. util/route-loader instead of ./util/route-loader
if (
modulePath.includes('/') &&
! (modulePath.startsWith('./') || modulePath.includes(__dirname))
) {
includeName = path.join(__dirname, modulePath);
}
return require(includeName);
}
}
let instance = null;
module.exports = (function () {
if (instance === null) {
instance = new Container();
}
return instance;
}());

View File

@ -1,23 +0,0 @@
'use strict';
const container = require('./Container');
const loadRoutes = container.require('util/route-loader'),
path = container.require('path');
const app = container.get('app');
const middleware = container.require('config/middleware');
const routes = loadRoutes(path.join(__dirname, 'routes'));
const errorHandlers = container.require('config/error-handlers');
// load middleware
middleware.forEach((mw) => app.use(mw));
// automatically set up routing by folder structure
Object.keys(routes).reverse().forEach((path) => {
app.use(path, require(routes[path]));
});
// load error handlers
errorHandlers.forEach((handler) => app.use(handler));
module.exports = app;

View File

@ -1,10 +0,0 @@
'use strict';
const container = require('../Container');
const Model = container.require('base/Model');
class ApiModel extends Model {
}
module.exports = ApiModel;

View File

@ -1,32 +0,0 @@
'use strict';
let nconf = require('nconf');
/**
* Config management class
*/
class Config {
constructor() {
// Use, in order of preference:
// - command line arguments
// - environment variables
// - config files
nconf.argv()
.env()
.file({
});
}
/**
* Retreive a config value
*
* @param {String} key - the name of the config value
* @return {mixed} - the configuration value
*/
get(key) {
}
}
module.exports = Config;

View File

@ -1,69 +0,0 @@
'use strict';
const http = require('http'),
logger = require('winston');
/**
* Class for creating an http server
*
* @param {Express} app - current express instance
* @param {Number} port - the port to listen on
*/
class HttpServer {
/**
* Creates an HTTP Server
*
* @constructor
* @param {Express} app - current express instance
* @param {Number} port - the port to listen on
*/
constructor(app, port) {
let server = http.createServer(app);
server.listen(port);
server.on('error', this.onError);
server.on('listening', () => {
let addr = server.address();
let bind = typeof addr === 'string'
? `pipe ${addr}`
: `port ${addr.port}`;
logger.info(`Listening on ${bind}`);
});
this.server = server;
}
/**
* Event listener for HTTP server "error" event.
*
* @param {Error} error - the error object
* @return {Null} - Does not return a value
* @throws {Error}
*/
onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
let port = this.server.address().port;
let bind = typeof port === 'string'
? `Pipe ${port}`
: `Port ${port}`;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
logger.error(`${bind} requires elevated privileges`);
process.exit(1);
break;
case 'EADDRINUSE':
logger.error(`${bind} is already in use`);
process.exit(1);
break;
default:
throw error;
}
}
}
module.exports = HttpServer;

View File

@ -1,16 +0,0 @@
'use strict';
const express = require('express');
let router = express.Router();
/* GET home page. */
router.get('/', (req, res, next) => {
res.json({
status: 200,
data: {
index: { title: 'Express' },
},
});
});
module.exports = router;

View File

@ -1,11 +0,0 @@
'use strict';
const express = require('express');
let router = express.Router();
/* GET users listing. */
router.get('/', (req, res, next) => {
res.send('respond with a resource');
});
module.exports = router;

View File

@ -1,9 +1,11 @@
'use strict'; 'use strict';
const container = require('./lib/Container'); const fs = require('fs');
const app = container.require('./app'), const container = require('./app/Container');
logger = require('winston'), const app = container.get('./bootstrap');
HttpServer = container.require('base/HttpServer'); const config = container.get('base/Config');
const HttpServer = container.get('base/HttpServer');
const HttpsServer = container.get('base/HttpsServer');
/** /**
* Normalize a port into a number, string, or false. * Normalize a port into a number, string, or false.
@ -28,10 +30,22 @@ function normalizePort(val) {
return false; return false;
} }
// Get port from environment and store in Express.
let port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
// Create HTTP Server // Create HTTP Server
let server = new HttpServer(app, port); if (true === config.get('http')) {
let port = normalizePort(config.get('http-port'));
app.set('port', port);
container.set('http-server', new HttpServer(app, port));
}
// Create HTTPs Server
if (true === config.get('https')) {
const httpsPort = normalizePort(config.get('https-port'));
const httpsConfig = {
key: fs.readFileSync(config.get('https-config-key')),
cert: fs.readFileSync(config.get('https-config-cert')),
};
app.set('https-port', httpsPort);
container.set('https-server', new HttpsServer(app, httpsPort, httpsConfig));
}
// End of server.js

View File

@ -1,11 +1,12 @@
'use strict'; 'use strict';
const supertest = require('supertest'), const testBase = require('../test-base');
expect = require('chai').expect; const expect = testBase.expect;
const supertest = require('supertest');
let server = supertest.agent('http://localhost:3000'); let server = supertest.agent('http://localhost:3000');
suite('Example tests', () => { suite('Example integration tests', () => {
test('Index page works as expected', (done) => { test('Index page works as expected', (done) => {
server.get('/') server.get('/')
.expect('Content-type', /json/) .expect('Content-type', /json/)
@ -19,33 +20,34 @@ suite('Example tests', () => {
index: { title: 'Express' }, index: { title: 'Express' },
}, },
}); });
done(); return done(err);
}); });
}); });
test('404 page works as expected', (done) => { test('404 page works as expected', (done) => {
server.get('/foo') server.get('/foo')
.expect('Content-type', /json/) .expect('Content-type', /json/)
.expect(404)
.end((err, res) => { .end((err, res) => {
expect(res.status).to.equal(404); expect(res.status).to.equal(404);
expect(res.body.status).to.equal(res.status); expect(res.body.status).to.equal(String(res.status));
expect(res.body.message).to.be.ok; expect(res.body.message).to.be.ok;
let expected = { let expected = {
status: 404, status: '404',
message: 'Not Found', message: 'Not Found',
}; };
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
expected.error = { expected.error = {
status: 404, code: '404',
status: '404',
name: 'Http404Error',
message: 'Not Found',
}; };
} }
expect(res.body).to.deep.equal(expected); expect(res.body).to.deep.equal(expected);
done(); return done(err);
}); });
}); });
}); });

View File

@ -2,6 +2,17 @@
const path = require('path'); const path = require('path');
// Set up chai as promised to allow for
// better testing of promises
const chai = require('chai');
const chaiAsPromised = require('chai-as-promised');
chai.use(chaiAsPromised);
// Load environment file
require('dotenv').config({
path: path.resolve(__dirname, '../.env'),
});
/** /**
* Base Object for unit test utilities * Base Object for unit test utilities
*/ */
@ -10,7 +21,7 @@ const testBase = {
/** /**
* Chai expect assertion library * Chai expect assertion library
*/ */
expect: require('chai').expect, expect: chai.expect,
/** /**
* Determine the appropriate path to a module relative to the root folder * Determine the appropriate path to a module relative to the root folder
@ -38,7 +49,7 @@ const testBase = {
* @param {String} modulePath - path to the module, relative to the tests/ folder * @param {String} modulePath - path to the module, relative to the tests/ folder
* @return {mixed} - whatever the module returns * @return {mixed} - whatever the module returns
*/ */
testRequire(modulePath) { require(modulePath) {
const includePath = testBase._normalizeIncludePath(modulePath); const includePath = testBase._normalizeIncludePath(modulePath);
return require(includePath); return require(includePath);
}, },
@ -50,7 +61,7 @@ const testBase = {
* @param {String} modulePath - path to the module, relative to the tests/ folder * @param {String} modulePath - path to the module, relative to the tests/ folder
* @return {mixed} - whatever the module returns * @return {mixed} - whatever the module returns
*/ */
testRequireNoCache(modulePath) { requireNoCache(modulePath) {
const includePath = testBase._normalizeIncludePath(modulePath); const includePath = testBase._normalizeIncludePath(modulePath);
delete require.cache[includePath]; delete require.cache[includePath];
return require(includePath); return require(includePath);

View File

@ -0,0 +1,109 @@
'use strict';
const testBase = require('../../test-base');
const expect = testBase.expect;
const glob = require('glob');
const path = require('path');
suite('Config class tests', () => {
let config = null;
setup(() => {
config = testBase.require('app/base/Config');
});
test('Get a non-existent config value', () => {
let actual = config.get('foo');
expect(actual).to.be.undefined;
});
suite('Value cascade', () => {
suite('Explicit values are loaded first', () => {
setup(() => {
config._set('foo', 'bar');
});
test(`'foo' exists in config`, () => {
expect(config.has('foo')).to.be.true;
});
test(`value of 'foo' is returned by get method`, () => {
expect(config.get('foo')).to.be.equal('bar');
});
});
suite('Environment values are loaded before defaults', () => {
setup(() => {
process.env.BAR = 'baz';
});
test(`'bar' is not directly defined in config`, () => {
expect(config.has('bar')).to.be.false;
});
test(`'bar' is defined in environment`, () => {
expect(config.hasEnv('bar')).to.be.true;
expect(config.getEnv('bar')).to.be.equal(process.env.BAR);
});
test(`Gets 'bar' from environment`, () => {
expect(config.get('bar')).to.be.equal(config.getEnv('bar'));
});
test(`'foo' is now cached in config`, () => {
expect(config.has('bar')).to.be.true;
});
teardown(() => {
delete process.env.BAR;
});
});
suite('Default values are loaded as a last resort', () => {
setup(() => {
delete process.env.HOST;
});
test(`'host' is not directly defined in config`, () => {
expect(config.has('host')).to.be.false;
});
test(`'host' is not defined in environment`, () => {
expect(config.hasEnv('host')).to.be.false;
});
test(`Get default value of 'host'`, () => {
expect(config.get('host')).to.equal('localhost');
});
test(`'host' is now cached in config`, () => {
expect(config.has('host')).to.be.true;
});
});
});
suite('normalizeValue', () => {
test(`'true' returns true`, () => {
let val = config._normalizeValue('true');
expect(val).to.be.true;
});
test(`'false' returns false`, () => {
let val = config._normalizeValue('false');
expect(val).to.be.false;
});
test(`'436' returns 436`, () => {
let val = config._normalizeValue('436');
expect(val).to.be.equal(436);
});
test(`'foo' returns 'foo'`, () => {
let val = config._normalizeValue('foo');
expect(val).to.be.equal('foo');
});
});
test('Keys returns values in config', () => {
// Get the list of keys for files
let configFiles = glob.sync(
path.resolve(__dirname, '../../../app/config/**/*.js')
);
let expected = configFiles.map((fullPath) => {
return path.basename(fullPath, '.js');
});
let actual = config.keys();
expect(actual).to.be.deep.equal(expected);
});
});

View File

@ -11,11 +11,11 @@ suite('Dependency Container tests', () => {
// at an earlier time. The container module has a saved state to keep track // at an earlier time. The container module has a saved state to keep track
// of modules during normal use. Removing the cached version ensures that // of modules during normal use. Removing the cached version ensures that
// a new instance of the module is used. // a new instance of the module is used.
container = testBase.testRequireNoCache('lib/Container'); container = testBase.requireNoCache('app/Container');
}); });
test('Multiple requires return the same instance', () => { test('Multiple requires return the same instance', () => {
let container2 = testBase.testRequire('lib/Container'); let container2 = testBase.require('app/Container');
expect(container2).to.be.equal(container); expect(container2).to.be.equal(container);
}); });
@ -54,21 +54,21 @@ suite('Dependency Container tests', () => {
expect(actual).to.be.equal(obj); expect(actual).to.be.equal(obj);
}); });
test('Attempt to get non-existent item returns null', () => { test('Attempt to get non-existent item returns undefined', () => {
expect(container.get('aseiutj')).to.be.null; expect(container.get('aseiutj')).to.be.undefined;
}); });
}); });
suite('require method', () => { suite('get method require', () => {
test('Returns same object as testInclude', () => { test('Returns same object as testInclude', () => {
let containerFile = container.require('app'); let containerFile = container.get('./bootstrap');
let testFile = testBase.testRequire('lib/app'); let testFile = testBase.require('app/bootstrap');
expect(containerFile).to.be.equal(testFile); expect(containerFile).to.be.equal(testFile);
}); });
test('Returns same object as native require', () => { test('Returns same object as native require', () => {
let containerFile = container.require('express'); let containerFile = container.get('express');
let nativeRequire = require('express'); let nativeRequire = require('express');
expect(containerFile).to.be.equal(nativeRequire); expect(containerFile).to.be.equal(nativeRequire);

View File

@ -0,0 +1,28 @@
'use strict';
const testBase = require('../../test-base');
const errors = require('errors');
const expect = testBase.expect;
suite('Controller tests', () => {
let controller = null;
setup(() => {
controller = testBase.requireNoCache('app/base/Controller');
});
suite('HttpError Tests', () => {
test('500 Error', (done) => {
controller.HttpError(500, (err) => {
expect(err).to.deep.equal(new errors.Http500Error());
return done();
});
});
test('401 Error', (done) => {
controller.HttpError(401, (err) => {
expect(err).to.deep.equal(new errors.Http401Error());
return done();
});
});
});
});

View File

@ -0,0 +1,25 @@
'use strict';
const path = require('path');
const testBase = require('../../../test-base');
const expect = testBase.expect;
let routeLoader = testBase.require('app/base/util/route-loader');
function getPath(filePath) {
return path.join(__dirname, filePath)
.replace(/\\/g, '/');
}
suite('Util - Route Loader', () => {
test('routeLoader creates accurate route mapping', () => {
let actual = routeLoader(path.join(__dirname, 'test-routes'));
let expected = {
'/api/foo/bar': getPath('test-routes/api/foo/bar.js'),
'/api/foo': getPath('test-routes/api/foo.js'),
'/': getPath('test-routes/index.js'),
};
expect(expected).to.be.deep.equal(actual);
});
});

View File

@ -0,0 +1,21 @@
'use strict';
const testBase = require('../../test-base');
const expect = testBase.expect;
const fs = require('fs');
const promisify = testBase.require('app/helpers/promisify');
suite('Promisify', () => {
test('Promisify returns a promise', () => {
let actual = promisify(fs.readFile)('../../test-base.js');
expect(actual).to.be.a('Promise');
});
test('Promisify fs.readFile resolves', () => {
let actual = promisify(fs.readFile)('../../test-base.js');
expect(actual).to.be.fulfilled;
});
test('Promisify fs.readFile fails on non-existent file', () => {
let actual = promisify(fs.readFile)('foo.txt');
expect(actual).to.be.rejected;
});
});

View File

@ -1,18 +0,0 @@
'use strict';
const expect = require('chai').expect,
path = require('path');
let routeLoader = require(path.join(__dirname, '/../../../lib/util/route-loader'));
suite('Util - Route Loader', () => {
test('routeLoader creates accurate route mapping', () => {
let actual = routeLoader(path.join(__dirname, 'test-routes'));
let expected = {
'/api/foo/bar': path.join(__dirname, 'test-routes/api/foo/bar.js'),
'/api/foo': path.join(__dirname, 'test-routes/api/foo.js'),
'/': path.join(__dirname, 'test-routes/index.js'),
};
expect(expected).to.be.deep.equal(actual);
});
});