fwsp-hydra-express
Version:
A module which wraps Hydra and ExpressJS to provide an out of the box microservice which can support API routes and handlers.
678 lines (616 loc) • 19.8 kB
JavaScript
/*eslint-disable no-unused-vars */
/**
* HydraExpress Module
* @description A module that binds Hydra and ExpressJS. This simplifies building API enabled microservices.
* @author Carlos Justiniano
*/
;
const Promise = require('bluebird');
Promise.series = (iterable, action) => {
return Promise.mapSeries(
iterable.map(action),
(value, index, length) => value || iterable[index].name || null
);
};
const ServerResponse = require('fwsp-server-response');
let serverResponse = new ServerResponse();
const bodyParser = require('body-parser');
const cluster = require('cluster');
const cors = require('cors');
const express = require('express');
const helmet = require('helmet');
const http = require('http');
const moment = require('moment');
const os = require('os');
const path = require('path');
const responseTime = require('response-time');
const Utils = require('fwsp-jsutils');
const jwtAuth = require('fwsp-jwt-auth');
const hydra = require('fwsp-hydra');
const HTTP_OK = 200;
const HTTP_UNAUTHORIZED = 401;
const HTTP_NOT_FOUND = 404;
const HTTP_SERVER_ERROR = 500;
let app = express();
let defaultLogger = () => {
let dump = (level, obj) => {
console.log(level.toUpperCase());
console.dir(obj, {colors: true, depth: null});
};
return {
fatal: obj => dump('FATAL', obj),
error: obj => dump('ERROR', obj),
debug: obj => dump('DEBUG', obj),
info: obj => dump('INFO', obj)
};
};
/**
* @name HydraExpress
* @summary HydraExpress class
*/
class HydraExpress {
constructor() {
this.config = null;
this.server = null;
this.appLogger = defaultLogger();
this.registeredPlugins = [];
}
/**
* @name use
* @summary Adds plugins to Hydra
* @param {...object} plugins - plugins to register
* @return {object} - Promise which will resolve when all plugins are registered
*/
use(...plugins) {
return Promise.series(plugins, plugin => this._registerPlugin(plugin));
}
/**
* @name _registerPlugin
* @summary Registers a plugin with Hydra
* @param {object} plugin - HydraPlugin to use
* @return {object} Promise or value
*/
_registerPlugin(plugin) {
this.registeredPlugins.push(plugin);
return plugin.setHydraExpress(this);
}
/**
* @name validateConfig
* @summary Validates a configuration object to ensure all required fields are present
* @private
* @param {object} config - config object
* @return {array} array - of missing fields or empty array
*/
validateConfig(config) {
let missingFields = [];
let requiredMembers = {
'hydra': {
'serviceName': '',
'serviceDescription': ''
},
'version': '',
'registerRoutesCallback': ''
};
Object.keys(requiredMembers).forEach((key) => {
let type = typeof requiredMembers[key];
if (type === 'string') {
if (config[key] === undefined) {
missingFields.push(key);
}
} else if (type === 'object') {
if (config[key] === undefined) {
missingFields.push(key);
} else {
Object.keys(requiredMembers[key]).forEach((key2) => {
if (config[key][key2] === undefined) {
missingFields.push(`${key}.${key2}`);
}
});
}
}
});
return missingFields;
}
/**
* @name init
* @summary Initialize HydraExpress using a configuration object.
* @private
* @throws Throws an Error() if config is found to be invalid
* @param {object} config - configuration as described in the projects readme
* @return {object} Promise - promise resolving to hydraexpress ready or failure
*/
init(config) {
return new Promise((resolve, reject) => {
if (!config.hydra) {
reject(new Error('Config missing hydra block'));
return;
}
if (!config.hydra.redis) {
reject(new Error('Config missing redis block'));
return;
}
config.hydra.serviceIP = config.hydra.serviceIP || '';
config.hydra.servicePort = config.hydra.servicePort || 0;
config.hydra.serviceType = config.hydra.serviceType || '';
let missingFields = this.validateConfig(config);
if (missingFields.length) {
reject(new Error(`Config missing fields: ${missingFields.join(' ')}`));
} else if (!config.version) {
reject(new Error('Config missing version parameter'));
} else if (!config.registerRoutesCallback) {
reject(new Error('Config missing registerRoutesCallback parameter'));
} else {
config.hydra.serviceVersion = config.version;
this.config = config;
this.config.environment = this.config.environment || 'development';
this.registerRoutesCallback = config.registerRoutesCallback;
/**
* Start the log event Listener as soon as possible in order to
* receive redis initialization errors.
*
* @param {string} entry - log entry
*/
hydra.on('log', (entry) => {
this.log(entry.type, entry.message);
});
return Promise.series(this.registeredPlugins, plugin => plugin.setConfig(config))
.then((...results) => {
if (config.jwtPublicCert) {
return jwtAuth.loadCerts(null, config.jwtPublicCert)
.catch(err => reject(new Error('Can\'t load public cert')));
}
})
.then(() => this.start(resolve, reject))
.catch(err => this.log('error', err.toString()));
}
});
}
/**
* @name _shutdown
* @summary Shutdown hydra-express safely.
*/
_shutdown() {
this.server.close(() => {
this.log('error', 'Service is shutting down.');
hydra.shutdown();
});
}
/**
* @name getExpress
* @summary Retrieve the ExpressJS object
* @return {object} express - ExpressJS object
*/
getExpress() {
return express;
}
/**
* @name getExpressApp
* @summary Retrieve the ExpressJS app object
* @return {object} app - express app object
*/
getExpressApp() {
return app;
}
/**
* @name getHydra
* @summary Retrieve the Hydra object
* @private
* @return {object} hydra - Hydra object
*/
getHydra() {
return hydra;
}
/**
* @name log
* @summary logs a message
* @private
* @param {string} type - type of message: 'info', 'start', 'error'
* @param {string} message - message to log
*/
log(type, message) {
let suppressLogEmit = true;
switch (type) {
case 'fatal':
this.appLogger.fatal({
event: type,
message
});
hydra.sendToHealthLog('fatal', message, suppressLogEmit);
break;
case 'error':
this.appLogger.error({
event: type,
message
});
hydra.sendToHealthLog('fatal', message, suppressLogEmit);
break;
case 'debug':
this.appLogger.debug({
event: type,
message
});
break;
default:
this.appLogger.info({
event: type,
message
});
break;
}
}
/**
* @name start
* @summary Starts the HydraExpress server
* @param {function} resolve - promise resolve
* @param {function} reject - promise reject
* @private
*/
start(resolve, reject) {
if (!this.config.cluster || this.config.cluster !== true) {
let serviceInfo;
hydra.init(this.config.hydra)
.then(() => {
return hydra.registerService();
})
.then(_serviceInfo => {
serviceInfo = _serviceInfo;
this.log('start', `${this.config.hydra.serviceName} (v.${this.config.version}) server listening on port ${this.config.hydra.servicePort}`);
this.log('info', `Using environment: ${this.config.environment}`);
this.initWorker();
return Promise.series(this.registeredPlugins, plugin => plugin.onServiceReady());
})
.then((...results) => {
return Promise.delay(2000);
})
.then(() => resolve(serviceInfo))
.catch(err => this.log('error', err.toString()));
} else {
if (cluster.isMaster) {
const numWorkers = this.config.processes || os.cpus().length;
console.log(`${this.config.hydra.serviceName} (v.${this.config.version})`);
console.log(`Using environment: ${this.config.environment}`);
console.log('info', `Master cluster setting up ${numWorkers} workers...`);
for (let i = 0; i < numWorkers; i++) {
cluster.fork();
}
/**
* @param {object} worker - worker process object
*/
cluster.on('online', (worker) => {
this.log('info', `Worker ${worker.process.pid} is online`);
});
/**
* @param {object} worker - worker process object
* @param {number} code - process exit code
* @param {number} signal - signal that caused the process shutdown
*/
cluster.on('exit', (worker, code, signal) => {
this.log('error', `Worker ${worker.process.pid} died with code ${code}, and signal: ${signal}`);
this.log('info', 'Starting a new worker');
cluster.fork();
});
resolve({});
} else {
hydra.init(this.config.hydra)
.then(() => {
return hydra.registerService();
})
.then((serviceInfo) => {
this.initWorker();
Promise.delay(2000).then(() => {
resolve({
serviceName: this.config.hydra.serviceName,
serviceIP: this.config.hydra.serviceIP,
servicePort: this.config.hydra.servicePort
});
});
});
}
}
}
/**
* @name initWorker
* @summary Initialize a worker process
* @private
*/
initWorker() {
app.use(cors());
app.use(responseTime());
/**
* @description Stamp every request with the process id that handled it.
* @param {object} req - express request object
* @param {object} res - express response object
* @param {function} next - express next handler
*/
app.use((req, res, next) => {
res.set('X-Process-Id', process.pid);
next();
});
/**
* @description Fatal error handler.
* @param {function} err - error handler function
*/
process.on('uncaughtException', (err) => {
let stack = err.stack;
delete err.__cached_trace__;
delete err.__previous__;
delete err.domain;
this.log('fatal', Utils.safeJSONStringify({
event: 'error',
error: err.name,
stack: stack
}));
process.exit(1);
});
/**
* Security.
*/
const ninetyDaysInMilliseconds = moment.duration(90, 'days').asMilliseconds();
app.use(helmet());
app.use(helmet.hidePoweredBy({setTo: `${this.config.hydra.serviceName}/${this.config.version}`}));
app.use(helmet.hsts({maxAge: ninetyDaysInMilliseconds}));
app.use(bodyParser.urlencoded({extended: false}));
app.use(bodyParser.json());
this.config.appPath = path.join('./', 'public');
app.use('/', express.static(this.config.appPath));
app.set('port', this.config.servicePort);
if (this.config.environment !== 'development') {
this.config.maxSockets = this.config.maxSockets || 500;
if (this.config.maxSockets) {
// increase max socket when used outside of development
http.globalAgent.maxSockets = this.config.maxSockets;
}
}
this.server = http.createServer(app);
/**
* @param {object} error - error object
* @description on handler for errors.
*/
this.server.on('error', (error) => {
if (error.syscall !== 'listen') {
throw error;
}
let bind = (typeof port === 'string') ? `Pipe ${this.config.hydra.servicePort}` : `Port ${this.config.hydra.servicePort}`;
switch (error.code) {
case 'EACCES':
this.log('error', `${bind} requires elevated privileges`);
process.exit(1);
break;
case 'EADDRINUSE':
this.log('error', `${bind} is already in use`);
process.exit(1);
break;
default:
throw error;
}
});
/**
* On SIGTERM perform graceful shutdown.
*/
process.on('SIGTERM', () => {
this.log('error', `Process ${process.pid} recieved SIGTERM - attempting graceful shutdown`);
this.server.close(() => {
process.exit(0);
});
});
/**
* @description listen handler for server.
*/
this.server.listen(this.config.hydra.servicePort, () => {
this.registerRoutesCallback && this.registerRoutesCallback();
app.get(`/_config/${this.config.hydra.serviceName}`, (req, res) => {
this.sendResponse(HTTP_OK, res, {result: this.config});
});
hydra.registerRoutes([`[GET]/_config/${this.config.hydra.serviceName}`]);
app.use('/*', (req, res) => {
res.sendFile(path.resolve(this.config.appPath + '/index.html'));
});
/**
* Post middleware init. Make sure to do this last.
*/
/**
* @param {object} req - express request object
* @param {object} res - express response object
* @param {function} next - express next handler
*/
app.use((req, res, next) => {
let err = new Error('Not Found');
err.status = HTTP_NOT_FOUND;
next(err);
});
/**
* @param {object} err - express err object
* @param {object} req - express request object
* @param {object} res - express response object
* @param {function} next - express next handler
*/
app.use((err, req, res, next) => {
let errCode = err.status || HTTP_SERVER_ERROR;
if (err.status !== HTTP_NOT_FOUND) {
this.appLogger.fatal({
event: 'error',
error: err.name,
stack: err.stack
});
}
res.status(errCode).json({
code: errCode
});
});
});
}
/**
* @name _registerRoutes
* @summary Register API routes.
* @private
* @param {object} routes - object with key/value pairs of routeBase: express api object
*/
_registerRoutes(routes) {
let routesList = [];
Object.keys(routes).forEach((routePath) => {
routes[routePath].stack.forEach((route) => {
let routeInfo = route.route;
// Skip router-level middleware, which will show up in the routes stack,
// but with an undefined route property
if (routeInfo === undefined) {
return;
}
Object.keys(routeInfo.methods).forEach((method) => {
routesList.push(`[${method}]${routePath}${routeInfo.path}`);
});
});
app.use(routePath, routes[routePath]);
});
if (routesList.length > 0) {
hydra.registerRoutes(routesList);
}
}
/**
* @name sendResponse
* @summary Send a server response to caller.
* @param {number} httpCode - HTTP response code
* @param {object} res - Node HTTP response object
* @param {object} data - An object to send
*/
_sendResponse(httpCode, res, data) {
serverResponse.sendResponse(httpCode, res, data);
}
/**
* @name _validateJwtToken
* @summary Express middleware to validate a JWT sent via the req.authorization header
* @return {function} Middleware function
*/
_validateJwtToken() {
return (req, res, next) => {
let authHeader = req.headers.authorization;
if (!authHeader) {
this.sendResponse(HTTP_UNAUTHORIZED, res, {
result: {
reason: 'Invalid token'
}
});
} else {
let token = authHeader.split(' ')[1];
if (token) {
return jwtAuth.verifyToken(token)
.then(decoded => {
req.authToken = decoded;
next();
})
.catch((err) => {
this.sendResponse(HTTP_UNAUTHORIZED, res, {
result: {
reason: err.message
}
});
});
} else {
this.sendResponse(HTTP_UNAUTHORIZED, res, {
result: {
reason: 'Invalid token'
}
});
}
}
};
}
}
/* ************************************************************************************************ */
/* ************************************************************************************************ */
/* ************************************************************************************************ */
/* ************************************************************************************************ */
/* ************************************************************************************************ */
/* ************************************************************************************************ */
/* ************************************************************************************************ */
/* ************************************************************************************************ */
/* ************************************************************************************************ */
/**
* @name IHydraExpress
* @summary Interface to a HydraExpress class
*/
class IHydraExpress extends HydraExpress {
constructor() {
super();
}
/**
* @name init
* @summary Initializes the HydraExpress module
* @param {object} config - application configuration object
* @param {string} version - version of application
* @param {function} registerRoutesCallback - callback function to register routes
* @return {object} Promise - promise resolving to hydraexpress ready or failure
*/
init(config, version, registerRoutesCallback) {
let inner = {};
if (typeof version === 'function') {
registerRoutesCallback = version;
inner.version = config.version || require('./package.json').version;
} else if (version) {
inner.version = version;
}
if (registerRoutesCallback) {
inner.registerRoutesCallback = registerRoutesCallback;
}
return super.init(Object.assign({}, config, inner));
}
/**
* @name shutdown
* @summary Shutdown hydra-express safely.
*/
shutdown() {
super._shutdown();
}
/**
* @name getExpress
* @summary Retrieve the underlying ExpressJS object
* @return {object} express - expressjs object
*/
getExpress() {
return super.getExpress();
}
/**
* @name getHydra
* @summary Retrieve the underlying Hydra object
* @return {object} hydra - hydra object
*/
getHydra() {
return super.getHydra();
}
/**
* @name log
* @summary Logger. Use to log messages
* @param {string} type - type of message: 'fatal', 'error', 'debug', 'info'
* @param {string} str - string message to log
*/
log(type, str) {
super.log(type, str);
}
/**
* @name registerRoutes
* @summary Register API routes.
* @param {string} routeBaseUrl - route base url, ex: /v1/offers
* @param {object} api - express api object
*/
registerRoutes(routeBaseUrl, api) {
super._registerRoutes(routeBaseUrl, api);
}
/**
* @name sendResponse
* @summary Send a server response to caller.
* @param {number} httpCode - HTTP response code
* @param {object} res - Node HTTP response object
* @param {object} data - An object to send
*/
sendResponse(httpCode, res, data) {
super._sendResponse(httpCode, res, data);
}
/**
* @name validateJwtToken
* @summary Express middleware to validate a JWT sent via the req.authorization header
* @return {function} Middleware function
*/
validateJwtToken() {
return super._validateJwtToken();
}
}
module.exports = new IHydraExpress;