First commit

This commit is contained in:
Timothy Warren 2016-02-19 12:58:16 -05:00
commit 2403bdfcee
37 changed files with 1844 additions and 0 deletions

18
.env.example Normal file
View File

@ -0,0 +1,18 @@
################################################################################
# Environment Configuration
################################################################################
NODE_ENV=development
HOST=localhost
# Enable/disable protocols
HTTP=true
HTTPS=false
# Server ports
HTTP_PORT=8000
HTTPS_PORT=3443
# Certificate paths are relative to the server.js file
HTTPS_CONFIG_KEY=localhost.key
HTTPS_CONFIG_CERT=localhost.crt

73
.gitignore vendored Normal file
View File

@ -0,0 +1,73 @@
# Created by https://www.gitignore.io/api/node,osx,webstorm,eclipse
### Node ###
# Logs
logs
*.log
npm-debug.log*
# Runtime data
pids
*.pid
*.seed
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directory
# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
node_modules
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
### OSX ###
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# Don't commit generated docs
public/docs/*
public/api-docs/*
public/coverage/*
# Don't commit environment file
.env

111
.jscsrc Normal file
View File

@ -0,0 +1,111 @@
{
"disallowEmptyBlocks": true,
"disallowKeywords": [
"with",
"eval"
],
"disallowKeywordsOnNewLine": [
"else"
],
"disallowMixedSpacesAndTabs": true,
"disallowMultipleLineBreaks": true,
"disallowMultipleLineStrings": true,
"disallowQuotedKeysInObjects": true,
"disallowSpaceAfterObjectKeys": true,
"disallowSpaceAfterPrefixUnaryOperators": false,
"disallowSpaceBeforeBinaryOperators": [
","
],
"disallowSpaceBeforeComma": {
"allExcept": [
"sparseArrays"
]
},
"disallowSpaceBeforePostfixUnaryOperators": [
"++",
"--"
],
"disallowSpaceBeforeSemicolon": true,
"disallowSpacesInCallExpression": true,
"disallowSpacesInFunctionDeclaration": {
"beforeOpeningRoundBrace": true
},
"disallowSpacesInNamedFunctionExpression": {
"beforeOpeningRoundBrace": true
},
"disallowSpacesInsideArrayBrackets": true,
"disallowSpacesInsideBrackets": true,
"disallowSpacesInsideParentheses": {
"only": [
"!",
"{",
"}"
]
},
"disallowTrailingWhitespace": true,
"disallowYodaConditions": true,
"esnext": true,
"requireBlocksOnNewline": 1,
"requireCamelCaseOrUpperCaseIdentifiers": true,
"requireCapitalizedConstructors": true,
"requireCommaBeforeLineBreak": true,
"requireCurlyBraces": [
"if",
"else",
"for",
"while",
"do",
"try",
"catch"
],
"requireDotNotation": {
"allExcept": [
"keywords"
]
},
"requireLineFeedAtFileEnd": false,
"requirePaddingNewLinesAfterBlocks": true,
"requirePaddingNewLinesBeforeLineComments": {
"allExcept": "firstAfterCurly"
},
"requireParenthesesAroundIIFE": true,
"requireSemicolons": true,
"requireSpaceAfterBinaryOperators": true,
"requireSpaceAfterComma": true,
"requireSpaceAfterKeywords": [
"if",
"else",
"for",
"while",
"do",
"switch",
"case",
"return",
"try",
"catch",
"typeof"
],
"requireSpaceBeforeBinaryOperators": true,
"requireSpaceBeforeBlockStatements": true,
"requireSpaceBetweenArguments": true,
"requireSpacesInAnonymousFunctionExpression": {
"beforeOpeningRoundBrace": true,
"allExcept": [
"shorthand"
]
},
"requireSpacesInConditionalExpression": true,
"requireSpacesInForStatement": true,
"requireSpacesInsideObjectBrackets": "all",
"requireTrailingComma": {
"ignoreSingleLine": true
},
"safeContextKeyword": "_this",
"validateIndentation": "\t",
"validateLineBreaks": "LF",
"validateQuoteMarks": {
"mark": "'",
"escape": true,
"ignoreJSX": true
}
}

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Timothy Warren
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# ProgBlog
A simple node blog with built-in code snippet highlighting

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;

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

@ -0,0 +1,180 @@
'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', true],
['http-port', 80],
['https', false],
['https-port', 443],
['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;

10
app/base/Model.js Normal file
View File

@ -0,0 +1,10 @@
'use strict';
/**
* Base Model class
*/
class Model {
}
module.exports = 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

@ -0,0 +1,30 @@
'use strict';
const glob = require('glob');
/**
* Map Routes to route files
*
* @param {string} path - folder with Routes
* @return {Object} - Object mapping routes to their files
*/
module.exports = function routeLoader(path) {
const basePath = path.replace(/\\/g, '/');
let paths = glob.sync(`${path}/**/*.js`);
paths = paths.sort();
paths = paths.map((path) => path.replace('\\', '/'));
let routes = {};
paths.forEach((path) => {
let routePath = path.replace(basePath, '')
.replace('.js', '')
.replace('index', '');
routes[routePath] = path;
});
return routes;
};

31
app/bootstrap.js vendored Normal file
View File

@ -0,0 +1,31 @@
'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');
// Set up templating
const view = Config.get('view-engine');
view.setup(app);
// 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

@ -0,0 +1,43 @@
'use strict';
// -----------------------------------------------------------------------------
// Error handlers
// -----------------------------------------------------------------------------
const container = require('../Container');
const app = container.get('app');
const HTTP_CODE_MAP = require('http').STATUS_CODES;
const errors = require('errors');
let errorHandlers = new Set([
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
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: httpStatus,
message: message,
};
// Show stack trace in development environment
if (app.get('env') === 'development') {
output.error = err;
}
res.json(output);
},
]);
module.exports = errorHandlers;

40
app/config/middleware.js Normal file
View File

@ -0,0 +1,40 @@
'use strict';
// -----------------------------------------------------------------------------
// Middleware
// -----------------------------------------------------------------------------
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');
const express = require('express');
const helmet = require('helmet');
const requestLogger = require('morgan');
const path = require('path');
const session = require('express-session');
let middleware = new Set([
// some security settings controlled by helmet
helmet.frameguard(),
helmet.hidePoweredBy(),
helmet.ieNoOpen(),
helmet.noSniff(),
helmet.xssFilter(),
// session setup
session({
name: 'blogid',
resave: false,
saveUninitialized: false,
secret: '76al;p9vse',
}),
// basic express middleware
requestLogger('combined'),
bodyParser.json(),
bodyParser.urlencoded({ extended: false }),
cookieParser(),
express.static(path.join(__dirname, '../../public')),
]);
module.exports = middleware;

15
app/config/view-engine.js Normal file
View File

@ -0,0 +1,15 @@
'use strict';
// Stupid template engine requires coffescript for some reason
const cs = require('coffee-script');
cs.register();
const path = require('path');
const hulk = require('hulk-hogan');
module.exports.setup = (app) => {
let viewPath = path.resolve(__dirname, '../views');
app.set('views', viewPath);
app.set('view options', { layout: false });
app.set('view engine', 'hulk');
};

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*/

245
gulpfile.js Normal file
View File

@ -0,0 +1,245 @@
'use strict';
const apidoc = require('gulp-apidoc'),
documentation = require('gulp-documentation'),
eslint = require('gulp-eslint'),
gulp = require('gulp'),
istanbul = require('gulp-istanbul'),
jscs = require('gulp-jscs'),
mocha = require('gulp-mocha'),
pipe = require('gulp-pipe'),
nsp = require('gulp-nsp');
/*
* Path(s) to all source files
*/
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
*/
const UNIT_TEST_FILES = ['test/unit/**/*_test.js'];
/*
* Path to integration test files
*/
const INTEGRATION_TEST_FILES = ['test/integration/**/*_test.js'];
/*
* Path(s) to all test files
*/
const TEST_FILES = ['test/**/*_test.js'];
/*
* Configuration values for eslint
*/
const ESLINT_SETTINGS = {
env: {
node: true,
es6: true,
},
// Each rule has an error level (0-2), and some have extra parameters
// 0 turns a rule off
// 1 makes a rule give a warning
// 2 makes a rule fail linting
rules: {
'linebreak-style': [2, 'unix'], // Only unix line endings
'arrow-parens': [2, 'always'], // No parens on arrow functions with one param
'no-console': [1], // Avoid using console methods
'no-constant-condition': [1],
'no-extra-semi': [1], // Enliminate extra semicolons
'no-func-assign': [1],
'no-obj-calls': [2],
'no-unexpected-multiline': [2], // Catch syntax errors due to automatic semicolon insertion
'no-unneeded-ternary': [2], // Avoid redundant ternary expressions
radix: [2], // Require radix parameter on parseInt
'no-with': [2], // No use of with construct
'no-eval': [2], // No use of eval
'no-unreachable': [1], // Avoid code that is not reachable
'no-irregular-whitespace': [1], // Highlight whitespace that isn't a tab or space
'no-new-wrappers': [2], // Avoid using primitive constructors
'no-new-func': [2], // Avoid Function constructor eval
curly: [2, 'multi-line'], // Require braces for if,for,while,do contructs that are not on the same line
'no-implied-eval': [2], // Avoid camoflauged eval
'no-invalid-this': [2],
'constructor-super': [2],
'no-dupe-args': [2], // Disallow functions to have more than one parameter with the same name
'no-dupe-keys': [2], // Disaalow objects to have more than one property with the same name
'no-dupe-class-members': [2], // Disallow classes to have more than one method/memeber with the same name
'no-this-before-super': [2],
'prefer-arrow-callback': [1], // Prefer arrow functions for callbacks
'no-var': [2], // Use let or const instead of var
'valid-jsdoc': [1],
semi: [2, 'always'], // Require use of semicolons
strict: [2, 'global'], // have a global 'use strict' in every code file
'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
*/
gulp.task('lint-tests', () => {
const LINT_TESTS_FILES = TEST_FILES.concat([
'gulpfile.js',
'server.js',
]);
// eslint
pipe(gulp.src(LINT_TESTS_FILES), [
eslint(ESLINT_SETTINGS),
eslint.format(),
eslint.failAfterError(),
]);
// JSCS rules are defined in /.jscsrc
pipe(gulp.src(['test/**/*.js', 'gulpfile.js']), [
jscs(),
jscs.reporter(),
]);
});
/*
* Check syntax and style of source files
*/
gulp.task('lint-src', () => {
// eslint
pipe(gulp.src(SRC_FILES), [
eslint(ESLINT_SETTINGS),
eslint.format(),
eslint.failAfterError(),
]);
// JSCS
// JSCS rules are defined in /.jscsrc
pipe(gulp.src(SRC_FILES), [
jscs(),
jscs.reporter(),
]);
});
/*
* Run all lint tasks
*/
gulp.task('lint', ['lint-src', 'lint-tests']);
/*
* Create internal method documentation of source files
*/
gulp.task('src-docs', () => {
pipe(gulp.src(SRC_FILES), [
documentation({
format: 'html',
}),
gulp.dest('public/docs'),
]);
});
/*
* Create api documentation from source files
*/
gulp.task('api-docs', (done) => {
apidoc({
src: 'app/',
dest: 'public/api-docs/',
}, done);
});
/*
* Run all documentation generation tasks
*/
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 gulp.src(TEST_FILES)
.pipe(mocha(MOCHA_SETTINGS));
});
/*
* Run hooks for istanbul coverage generation
*/
gulp.task('pre-coverage', () => {
return pipe(gulp.src(SRC_FILES), [
istanbul(),
istanbul.hookRequire(),
]);
});
/*
* Run unit tests and generate code coverage
*
* Does not run integration tests
*/
gulp.task('coverage', ['lint', 'pre-coverage'], () => {
return pipe(gulp.src(UNIT_TEST_FILES), [
mocha(MOCHA_SETTINGS),
istanbul.writeReports({
dir: 'public/coverage',
reporters:['lcov', 'lcovonly', 'html', 'text'],
}),
]);
});
/*
* Check dependencies for known security vulnerabilites
*/
gulp.task('audit', (cb) => {
nsp({
package: `${__dirname}/package.json`,
}, cb);
});
/*
* Run all tasks
*/
gulp.task('default', [
'audit',
'lint',
'docs',
'coverage',
]);
// End of gulpfile.js

74
package.json Normal file
View File

@ -0,0 +1,74 @@
{
"author": "Timothy J. Warren",
"dependencies": {
"axios": "^0.9.1",
"body-parser": "~1.13.2",
"ci-node-query": "^3.1.0",
"coffee-script": "^1.10.0",
"cookie-parser": "~1.3.5",
"debug": "~2.2.0",
"dotenv": "^2.0.0",
"errors": "^0.3.0",
"eslint": "^1.10.3",
"express": "4.*",
"express-session": "^1.13.0",
"getargs": "0.0.8",
"glob": "^6.0.4",
"helmet": "^1.1.0",
"highlight.js": "^9.1.0",
"hulk-hogan": "0.0.9",
"lodash": "^4.5.0",
"marked": "^0.3.5",
"morgan": "~1.6.1",
"nodemon": "^1.9.0",
"winston": "^2.1.1"
},
"description": "An Opinionated Take on express with use of ES6 features",
"devDependencies": {
"chai": "^3.4.1",
"chai-as-promised": "^5.2.0",
"eslint": "1.10.*",
"gulp": "3.9.*",
"gulp-apidoc": "0.2.*",
"gulp-documentation": "2.1.*",
"gulp-eslint": "^2.0.0",
"gulp-istanbul": "^0.10.3",
"gulp-jscs": "3.0.*",
"gulp-mocha": "2.2.*",
"gulp-nsp": "^2.3.0",
"gulp-pipe": "1.0.*",
"istanbul": "0.4.*",
"mocha": "2.3.*",
"pre-commit": "^1.1.2",
"supertest": "^1.1.0"
},
"directories": {
"lib": "./lib"
},
"engines": {
"node": ">4.0.0"
},
"files": [
"lib/",
"server.js"
],
"license": "MIT",
"main": "./server.js",
"name": "crispy-train",
"pre-commit": {
"silent": false,
"run": [
"gulp"
]
},
"repository": {
"type": "git",
"url": "https://github.com/timw4mail/crispy-train.git"
},
"scripts": {
"start": "nodemon server.js",
"gulp": "gulp default",
"test": "gulp test"
},
"version": "0.0.1"
}

0
public/.gitkeep Normal file
View File

51
server.js Normal file
View File

@ -0,0 +1,51 @@
'use strict';
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.
*
* @private
* @param {mixed} val - port value
* @return {mixed} - normalized value
*/
function normalizePort(val) {
let port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
}
// Create HTTP Server
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

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

5
test/mocha.opts Normal file
View File

@ -0,0 +1,5 @@
test/**/*.js
--ui tdd
--reporter spec
--slow 1000
--timeout 5000

71
test/test-base.js Normal file
View File

@ -0,0 +1,71 @@
'use strict';
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
*/
const testBase = {
/**
* Chai expect assertion library
*/
expect: chai.expect,
/**
* Determine the appropriate path to a module relative to the root folder
*
* @param {String} modulePath - the raw path to the module
* @return {String} - the normalized path to the module
*/
_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('./')) {
includePath = path.join(basePath, modulePath);
}
return includePath;
},
/**
* Load a module relative to the root folder
*
* @param {String} modulePath - path to the module, relative to the tests/ folder
* @return {mixed} - whatever the module returns
*/
require(modulePath) {
const includePath = testBase._normalizeIncludePath(modulePath);
return require(includePath);
},
/**
* Load a module relative to the root folder, but first delete
* the module from the require cache
*
* @param {String} modulePath - path to the module, relative to the tests/ folder
* @return {mixed} - whatever the module returns
*/
requireNoCache(modulePath) {
const includePath = testBase._normalizeIncludePath(modulePath);
delete require.cache[includePath];
return require(includePath);
},
};
module.exports = testBase;

0
test/unit/.gitkeep Normal file
View File

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

@ -0,0 +1,77 @@
'use strict';
const testBase = require('../../test-base');
const expect = testBase.expect;
suite('Dependency Container tests', () => {
let container = null;
setup(() => {
// Delete cached version of container class that may have been required
// 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.requireNoCache('app/Container');
});
test('Multiple requires return the same instance', () => {
let container2 = testBase.require('app/Container');
expect(container2).to.be.equal(container);
});
suite('has method', () => {
setup(() => {
container.set('foobar', {
foo: {
bar: [1, 2, 3],
},
});
});
test('Item "foobar" exists', () => {
expect(container.has('foobar')).to.be.true;
});
test('Item "abc" does not exist', () => {
expect(container.has('abc')).to.be.false;
});
});
suite('Get/set functionality', () => {
let obj = {
foo: {
bar: [1, 2, 3],
},
};
test('Set method returns Container', () => {
let actual = container.set('foobar', obj);
expect(actual).to.be.equal(container);
});
test('Get method returns set object', () => {
let actual = container.get('foobar');
expect(actual).to.be.equal(obj);
});
test('Attempt to get non-existent item returns undefined', () => {
expect(container.get('aseiutj')).to.be.undefined;
});
});
suite('get method require', () => {
test('Returns same object as testInclude', () => {
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.get('express');
let nativeRequire = require('express');
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,13 @@
'use strict';
const express = require('express');
let router = express.Router();
/* GET home page. */
router.get('/', (req, res, next) => {
res.json({
index: { title: 'Express' },
});
});
module.exports = router;

View File

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

View File

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

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;
});
});