diff --git a/app/Container.js b/app/Container.js new file mode 100644 index 0000000..f1bd158 --- /dev/null +++ b/app/Container.js @@ -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(); diff --git a/app/base/ApiModel.js b/app/base/ApiModel.js new file mode 100644 index 0000000..edc3d16 --- /dev/null +++ b/app/base/ApiModel.js @@ -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; \ No newline at end of file diff --git a/app/base/Config.js b/app/base/Config.js new file mode 100644 index 0000000..37633eb --- /dev/null +++ b/app/base/Config.js @@ -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(); diff --git a/app/base/Controller.js b/app/base/Controller.js new file mode 100644 index 0000000..e4abf7f --- /dev/null +++ b/app/base/Controller.js @@ -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; \ No newline at end of file diff --git a/app/base/HttpServer.js b/app/base/HttpServer.js new file mode 100644 index 0000000..e3ceafe --- /dev/null +++ b/app/base/HttpServer.js @@ -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; \ No newline at end of file diff --git a/app/base/HttpsServer.js b/app/base/HttpsServer.js new file mode 100644 index 0000000..db73f92 --- /dev/null +++ b/app/base/HttpsServer.js @@ -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; \ No newline at end of file diff --git a/lib/base/Model.js b/app/base/Model.js similarity index 66% rename from lib/base/Model.js rename to app/base/Model.js index df125a8..ec3e504 100644 --- a/lib/base/Model.js +++ b/app/base/Model.js @@ -1,5 +1,8 @@ 'use strict'; +/** + * Base Model class + */ class Model { } diff --git a/app/base/Server.js b/app/base/Server.js new file mode 100644 index 0000000..83ff440 --- /dev/null +++ b/app/base/Server.js @@ -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; \ No newline at end of file diff --git a/lib/util/route-loader.js b/app/base/util/route-loader.js similarity index 75% rename from lib/util/route-loader.js rename to app/base/util/route-loader.js index fa0a2b7..7e0dcbc 100644 --- a/lib/util/route-loader.js +++ b/app/base/util/route-loader.js @@ -5,15 +5,16 @@ const glob = require('glob'); /** * 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 */ module.exports = function routeLoader(path) { - const basePath = path; + const basePath = path.replace(/\\/g, '/'); let paths = glob.sync(`${path}/**/*.js`); paths = paths.sort(); + paths = paths.map((path) => path.replace('\\', '/')); let routes = {}; diff --git a/app/bootstrap.js b/app/bootstrap.js new file mode 100644 index 0000000..a2a7ea6 --- /dev/null +++ b/app/bootstrap.js @@ -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; + +}()); \ No newline at end of file diff --git a/app/config/container-autoload.js b/app/config/container-autoload.js new file mode 100644 index 0000000..33bc1d2 --- /dev/null +++ b/app/config/container-autoload.js @@ -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'], +]; \ No newline at end of file diff --git a/lib/config/error-handlers.js b/app/config/error-handlers.js similarity index 50% rename from lib/config/error-handlers.js rename to app/config/error-handlers.js index 2c0c629..3561bc1 100644 --- a/lib/config/error-handlers.js +++ b/app/config/error-handlers.js @@ -6,23 +6,29 @@ const container = require('../Container'); 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 - (req, res, next) => { - let err = new Error('Not Found'); - err.status = 404; - next(err); + function handle404(req, res, next) { + // if no route matches, send a 404 + if (! req.route) { + let err = new errors.Http404Error(); + return next(err); + } }, // general error handler - (err, req, res, next) => { - res.status(err.status || 500); + function handleError(err, req, res, next) { + let httpStatus = err.status || 500; + let message = err.message || HTTP_CODE_MAP[httpStatus]; + + res.status(httpStatus); let output = { - status: err.status, - message: err.message, + status: httpStatus, + message: message, }; // Show stack trace in development environment @@ -32,6 +38,6 @@ let errorHandlers = [ res.json(output); }, -]; +]); module.exports = errorHandlers; \ No newline at end of file diff --git a/lib/config/middleware.js b/app/config/middleware.js similarity index 96% rename from lib/config/middleware.js rename to app/config/middleware.js index 5214d72..d151e5a 100644 --- a/lib/config/middleware.js +++ b/app/config/middleware.js @@ -11,7 +11,7 @@ const bodyParser = require('body-parser'), requestLogger = require('morgan'), path = require('path'); -let middleware = [ +let middleware = new Set([ // some security settings controlled by helmet helmet.frameguard(), @@ -26,6 +26,6 @@ let middleware = [ bodyParser.urlencoded({ extended: false }), cookieParser(), express.static(path.join(__dirname, '../../public')), -]; +]); module.exports = middleware; \ No newline at end of file diff --git a/app/controllers/index.js b/app/controllers/index.js new file mode 100644 index 0000000..1c1ba9b --- /dev/null +++ b/app/controllers/index.js @@ -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(); + }, + }, +}; \ No newline at end of file diff --git a/app/helpers/promisify.js b/app/helpers/promisify.js new file mode 100644 index 0000000..b749eb9 --- /dev/null +++ b/app/helpers/promisify.js @@ -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*/ diff --git a/gulpfile.js b/gulpfile.js index 5fad31b..2eaf9d9 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -13,7 +13,14 @@ const apidoc = require('gulp-apidoc'), /* * 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 @@ -23,7 +30,7 @@ const UNIT_TEST_FILES = ['test/unit/**/*_test.js']; /* * 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 @@ -76,9 +83,23 @@ const ESLINT_SETTINGS = { 'callback-return': [1], // return when invoking a callback '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 + '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 */ @@ -143,7 +164,7 @@ gulp.task('src-docs', () => { */ gulp.task('api-docs', (done) => { apidoc({ - src: 'lib/', + src: 'app/', dest: 'public/api-docs/', }, done); }); @@ -153,18 +174,28 @@ gulp.task('api-docs', (done) => { */ 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 */ gulp.task('test', ['lint'], () => { - return pipe(gulp.src(TEST_FILES), [ - mocha({ - ui: 'tdd', - bail: true, - slow: 1000, - timeout: 5000, - }), - ]); + return gulp.src(TEST_FILES) + .pipe(mocha(MOCHA_SETTINGS)); }); /* @@ -180,16 +211,11 @@ gulp.task('pre-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'], () => { return pipe(gulp.src(UNIT_TEST_FILES), [ - mocha({ - ui: 'tdd', - bail: true, - slow: 1000, - timeout: 5000, - }), + mocha(MOCHA_SETTINGS), istanbul.writeReports({ dir: 'public/coverage', reporters:['lcov', 'lcovonly', 'html', 'text'], diff --git a/lib/Container.js b/lib/Container.js deleted file mode 100644 index 2c02364..0000000 --- a/lib/Container.js +++ /dev/null @@ -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; -}()); \ No newline at end of file diff --git a/lib/app.js b/lib/app.js deleted file mode 100644 index 6c06200..0000000 --- a/lib/app.js +++ /dev/null @@ -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; \ No newline at end of file diff --git a/lib/base/ApiModel.js b/lib/base/ApiModel.js deleted file mode 100644 index 6525c9f..0000000 --- a/lib/base/ApiModel.js +++ /dev/null @@ -1,10 +0,0 @@ -'use strict'; - -const container = require('../Container'); -const Model = container.require('base/Model'); - -class ApiModel extends Model { - -} - -module.exports = ApiModel; \ No newline at end of file diff --git a/lib/base/Config.js b/lib/base/Config.js deleted file mode 100644 index eb34174..0000000 --- a/lib/base/Config.js +++ /dev/null @@ -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; \ No newline at end of file diff --git a/lib/base/HttpServer.js b/lib/base/HttpServer.js deleted file mode 100644 index 1c5d736..0000000 --- a/lib/base/HttpServer.js +++ /dev/null @@ -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; \ No newline at end of file diff --git a/lib/routes/index.js b/lib/routes/index.js deleted file mode 100644 index 4e787ac..0000000 --- a/lib/routes/index.js +++ /dev/null @@ -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; \ No newline at end of file diff --git a/lib/routes/users.js b/lib/routes/users.js deleted file mode 100644 index 636a7d7..0000000 --- a/lib/routes/users.js +++ /dev/null @@ -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; diff --git a/server.js b/server.js index 813b2cc..078970a 100644 --- a/server.js +++ b/server.js @@ -1,9 +1,11 @@ 'use strict'; -const container = require('./lib/Container'); -const app = container.require('./app'), - logger = require('winston'), - HttpServer = container.require('base/HttpServer'); +const fs = require('fs'); +const container = require('./app/Container'); +const app = container.get('./bootstrap'); +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. @@ -28,10 +30,22 @@ function normalizePort(val) { return false; } - -// Get port from environment and store in Express. -let port = normalizePort(process.env.PORT || '3000'); -app.set('port', port); - // Create HTTP Server -let server = new HttpServer(app, port); \ No newline at end of file +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 \ No newline at end of file diff --git a/test/end-to-end/index_test.js b/test/integration/index_test.js similarity index 69% rename from test/end-to-end/index_test.js rename to test/integration/index_test.js index 23fd68a..5699cd0 100644 --- a/test/end-to-end/index_test.js +++ b/test/integration/index_test.js @@ -1,11 +1,12 @@ 'use strict'; -const supertest = require('supertest'), - expect = require('chai').expect; +const testBase = require('../test-base'); +const expect = testBase.expect; +const supertest = require('supertest'); let server = supertest.agent('http://localhost:3000'); -suite('Example tests', () => { +suite('Example integration tests', () => { test('Index page works as expected', (done) => { server.get('/') .expect('Content-type', /json/) @@ -19,33 +20,34 @@ suite('Example tests', () => { index: { title: 'Express' }, }, }); - done(); + return done(err); }); }); - test('404 page works as expected', (done) => { server.get('/foo') .expect('Content-type', /json/) - .expect(404) .end((err, res) => { 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; let expected = { - status: 404, + status: '404', message: 'Not Found', }; if (process.env.NODE_ENV === 'development') { expected.error = { - status: 404, + code: '404', + status: '404', + name: 'Http404Error', + message: 'Not Found', }; } expect(res.body).to.deep.equal(expected); - done(); + return done(err); }); }); }); \ No newline at end of file diff --git a/test/test-base.js b/test/test-base.js index 8350b19..f1de27b 100644 --- a/test/test-base.js +++ b/test/test-base.js @@ -2,6 +2,17 @@ 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 */ @@ -10,7 +21,7 @@ const testBase = { /** * Chai expect assertion library */ - expect: require('chai').expect, + expect: chai.expect, /** * 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 * @return {mixed} - whatever the module returns */ - testRequire(modulePath) { + require(modulePath) { const includePath = testBase._normalizeIncludePath(modulePath); return require(includePath); }, @@ -50,7 +61,7 @@ const testBase = { * @param {String} modulePath - path to the module, relative to the tests/ folder * @return {mixed} - whatever the module returns */ - testRequireNoCache(modulePath) { + requireNoCache(modulePath) { const includePath = testBase._normalizeIncludePath(modulePath); delete require.cache[includePath]; return require(includePath); diff --git a/test/unit/base/Config_test.js b/test/unit/base/Config_test.js new file mode 100644 index 0000000..80adb55 --- /dev/null +++ b/test/unit/base/Config_test.js @@ -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); + }); +}); \ No newline at end of file diff --git a/test/unit/base/Container_test.js b/test/unit/base/Container_test.js index 3892de9..2d8b0d9 100644 --- a/test/unit/base/Container_test.js +++ b/test/unit/base/Container_test.js @@ -11,11 +11,11 @@ suite('Dependency Container tests', () => { // 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 // 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', () => { - let container2 = testBase.testRequire('lib/Container'); + let container2 = testBase.require('app/Container'); expect(container2).to.be.equal(container); }); @@ -54,21 +54,21 @@ suite('Dependency Container tests', () => { expect(actual).to.be.equal(obj); }); - test('Attempt to get non-existent item returns null', () => { - expect(container.get('aseiutj')).to.be.null; + test('Attempt to get non-existent item returns undefined', () => { + expect(container.get('aseiutj')).to.be.undefined; }); }); - suite('require method', () => { + suite('get method require', () => { test('Returns same object as testInclude', () => { - let containerFile = container.require('app'); - let testFile = testBase.testRequire('lib/app'); + let containerFile = container.get('./bootstrap'); + let testFile = testBase.require('app/bootstrap'); expect(containerFile).to.be.equal(testFile); }); test('Returns same object as native require', () => { - let containerFile = container.require('express'); + let containerFile = container.get('express'); let nativeRequire = require('express'); expect(containerFile).to.be.equal(nativeRequire); diff --git a/test/unit/base/Controller_test.js b/test/unit/base/Controller_test.js new file mode 100644 index 0000000..42c517e --- /dev/null +++ b/test/unit/base/Controller_test.js @@ -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(); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/unit/base/util/route-loader_test.js b/test/unit/base/util/route-loader_test.js new file mode 100644 index 0000000..7e01482 --- /dev/null +++ b/test/unit/base/util/route-loader_test.js @@ -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); + }); +}); \ No newline at end of file diff --git a/test/unit/util/test-routes/api/foo.js b/test/unit/base/util/test-routes/api/foo.js similarity index 100% rename from test/unit/util/test-routes/api/foo.js rename to test/unit/base/util/test-routes/api/foo.js diff --git a/test/unit/util/test-routes/api/foo/bar.js b/test/unit/base/util/test-routes/api/foo/bar.js similarity index 100% rename from test/unit/util/test-routes/api/foo/bar.js rename to test/unit/base/util/test-routes/api/foo/bar.js diff --git a/test/unit/util/test-routes/index.js b/test/unit/base/util/test-routes/index.js similarity index 100% rename from test/unit/util/test-routes/index.js rename to test/unit/base/util/test-routes/index.js diff --git a/test/unit/helpers/promisify_test.js b/test/unit/helpers/promisify_test.js new file mode 100644 index 0000000..05fc28e --- /dev/null +++ b/test/unit/helpers/promisify_test.js @@ -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; + }); +}); \ No newline at end of file diff --git a/test/unit/util/route-loader_test.js b/test/unit/util/route-loader_test.js deleted file mode 100644 index 4e92ad2..0000000 --- a/test/unit/util/route-loader_test.js +++ /dev/null @@ -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); - }); -}); \ No newline at end of file