openhim-core
Version:
The OpenHIM core application that provides logging and routing of http requests
747 lines (637 loc) • 22.4 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.numberOfPrimaryRoutes = numberOfPrimaryRoutes;
exports.transformPath = transformPath;
exports.route = route;
exports.koaMiddleware = koaMiddleware;
var _zlib = require('zlib');
var _zlib2 = _interopRequireDefault(_zlib);
var _http = require('http');
var _http2 = _interopRequireDefault(_http);
var _https = require('https');
var _https2 = _interopRequireDefault(_https);
var _net = require('net');
var _net2 = _interopRequireDefault(_net);
var _tls = require('tls');
var _tls2 = _interopRequireDefault(_tls);
var _winston = require('winston');
var _winston2 = _interopRequireDefault(_winston);
var _cookie = require('cookie');
var _cookie2 = _interopRequireDefault(_cookie);
var _statsdClient = require('statsd-client');
var _statsdClient2 = _interopRequireDefault(_statsdClient);
var _os = require('os');
var _os2 = _interopRequireDefault(_os);
var _config = require('../config');
var _utils = require('../utils');
var utils = _interopRequireWildcard(_utils);
var _messageStore = require('../middleware/messageStore');
var messageStore = _interopRequireWildcard(_messageStore);
var _events = require('../middleware/events');
var events = _interopRequireWildcard(_events);
var _stats = require('../stats');
var stats = _interopRequireWildcard(_stats);
var _util = require('util');
function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } }
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
_config.config.mongo = _config.config.get('mongo');
_config.config.router = _config.config.get('router');
const statsdServer = _config.config.get('statsd');
const application = _config.config.get('application');
const domain = `${_os2.default.hostname()}.${application.name}.appMetrics`;
const sdc = new _statsdClient2.default(statsdServer);
const isRouteEnabled = route => route.status == null || route.status === 'enabled';
function numberOfPrimaryRoutes(routes) {
let numPrimaries = 0;
for (const route of Array.from(routes)) {
if (isRouteEnabled(route) && route.primary) {
numPrimaries++;
}
}
return numPrimaries;
}
const containsMultiplePrimaries = routes => numberOfPrimaryRoutes(routes) > 1;
function setKoaResponse(ctx, response) {
// Try and parse the status to an int if it is a string
let err;
if (typeof response.status === 'string') {
try {
response.status = parseInt(response.status, 10);
} catch (error) {
err = error;
_winston2.default.error(err);
}
}
ctx.response.status = response.status;
ctx.response.timestamp = response.timestamp;
ctx.response.body = response.body;
if (!ctx.response.header) {
ctx.response.header = {};
}
if (ctx.request != null && ctx.request.header != null && ctx.request.header['X-OpenHIM-TransactionID'] != null) {
if ((response != null ? response.headers : undefined) != null) {
response.headers['X-OpenHIM-TransactionID'] = ctx.request.header['X-OpenHIM-TransactionID'];
}
}
for (const key in response.headers) {
const value = response.headers[key];
switch (key.toLowerCase()) {
case 'set-cookie':
setCookiesOnContext(ctx, value);
break;
case 'location':
if (response.status >= 300 && response.status < 400) {
ctx.response.redirect(value);
} else {
ctx.response.set(key, value);
}
break;
case 'content-type':
ctx.response.type = value;
break;
case 'content-length':
case 'content-encoding':
case 'transfer-encoding':
// Skip headers which will be set internally
// These would otherwise interfere with the response
break;
default:
// Copy any other headers onto the response
ctx.response.set(key, value);
break;
}
}
}
if (process.env.NODE_ENV === 'test') {
exports.setKoaResponse = setKoaResponse;
}
function setCookiesOnContext(ctx, value) {
_winston2.default.info('Setting cookies on context');
const result = [];
for (let cValue = 0; cValue < value.length; cValue++) {
let pVal;
const cKey = value[cValue];
const cOpts = { path: false, httpOnly: false // clear out default values in cookie module
};const cVals = {};
const object = _cookie2.default.parse(cKey);
for (const pKey in object) {
pVal = object[pKey];
const pKeyL = pKey.toLowerCase();
switch (pKeyL) {
case 'max-age':
cOpts.maxage = parseInt(pVal, 10);
break;
case 'expires':
cOpts.expires = new Date(pVal);
break;
case 'path':
case 'domain':
case 'secure':
case 'signed':
case 'overwrite':
cOpts[pKeyL] = pVal;
break;
case 'httponly':
cOpts.httpOnly = pVal;
break;
default:
cVals[pKey] = pVal;
}
}
// TODO : Refactor this code when possible
result.push((() => {
const result1 = [];
for (const pKey in cVals) {
pVal = cVals[pKey];
result1.push(ctx.cookies.set(pKey, pVal, cOpts));
}
return result1;
})());
}
return result;
}
function handleServerError(ctx, err, route) {
ctx.autoRetry = true;
if (route) {
route.error = {
message: err.message,
stack: err.stack ? err.stack : undefined
};
} else {
ctx.response.status = 500;
ctx.response.timestamp = new Date();
ctx.response.body = 'An internal server error occurred';
// primary route error
ctx.error = {
message: err.message,
stack: err.stack ? err.stack : undefined
};
}
_winston2.default.error(`[${ctx.transactionId != null ? ctx.transactionId.toString() : undefined}] Internal server error occured: ${err}`);
if (err.stack) {
return _winston2.default.error(`${err.stack}`);
}
}
function sendRequestToRoutes(ctx, routes, next) {
const promises = [];
let promise = {};
ctx.timer = new Date();
if (containsMultiplePrimaries(routes)) {
return next(new Error('Cannot route transaction: Channel contains multiple primary routes and only one primary is allowed'));
}
return utils.getKeystore((err, keystore) => {
if (err) {
return err;
}
for (const route of Array.from(routes)) {
if (!isRouteEnabled(route)) {
continue;
}
const path = getDestinationPath(route, ctx.path);
const options = {
hostname: route.host,
port: route.port,
path,
method: ctx.request.method,
headers: ctx.request.header,
agent: false,
rejectUnauthorized: true,
key: keystore.key,
cert: keystore.cert.data,
secureProtocol: 'TLSv1_method'
};
if (route.cert != null) {
options.ca = keystore.ca.id(route.cert).data;
}
if (ctx.request.querystring) {
options.path += `?${ctx.request.querystring}`;
}
if (options.headers && options.headers.authorization && !route.forwardAuthHeader) {
delete options.headers.authorization;
}
if (route.username && route.password) {
options.auth = `${route.username}:${route.password}`;
}
if (options.headers && options.headers.host) {
delete options.headers.host;
}
if (route.primary) {
ctx.primaryRoute = route;
promise = sendRequest(ctx, route, options).then(response => {
_winston2.default.info(`executing primary route : ${route.name}`);
if (response.headers != null && response.headers['content-type'] != null && response.headers['content-type'].indexOf('application/json+openhim') > -1) {
// handle mediator reponse
const responseObj = JSON.parse(response.body);
ctx.mediatorResponse = responseObj;
if (responseObj.error != null) {
ctx.autoRetry = true;
ctx.error = responseObj.error;
}
// then set koa response from responseObj.response
return setKoaResponse(ctx, responseObj.response);
} else {
return setKoaResponse(ctx, response);
}
}).then(() => {
_winston2.default.info('primary route completed');
return next();
}).catch(reason => {
// on failure
handleServerError(ctx, reason);
return next();
});
} else {
_winston2.default.info(`executing non primary: ${route.name}`);
promise = buildNonPrimarySendRequestPromise(ctx, route, options, path).then(routeObj => {
_winston2.default.info(`Storing non primary route responses ${route.name}`);
try {
if ((routeObj != null ? routeObj.name : undefined) == null) {
routeObj = { name: route.name };
}
if ((routeObj != null ? routeObj.response : undefined) == null) {
routeObj.response = {
status: 500,
timestamp: ctx.requestTimestamp
};
}
if ((routeObj != null ? routeObj.request : undefined) == null) {
routeObj.request = {
host: options.hostname,
port: options.port,
path,
headers: ctx.request.header,
querystring: ctx.request.querystring,
method: ctx.request.method,
timestamp: ctx.requestTimestamp
};
}
return messageStore.storeNonPrimaryResponse(ctx, routeObj, () => stats.nonPrimaryRouteRequestCount(ctx, routeObj, () => stats.nonPrimaryRouteDurations(ctx, routeObj, () => {})));
} catch (err) {
return _winston2.default.error(err);
}
});
}
promises.push(promise);
}
return Promise.all(promises).then(() => messageStore.setFinalStatus(ctx, () => {
_winston2.default.info(`All routes completed for transaction: ${ctx.transactionId.toString()}`);
if (ctx.routes) {
_winston2.default.debug(`Storing route events for transaction: ${ctx.transactionId}`);
const done = err => {
if (err) {
return _winston2.default.error(err);
}
};
const trxEvents = [];
events.createSecondaryRouteEvents(trxEvents, ctx.transactionId, ctx.requestTimestamp, ctx.authorisedChannel, ctx.routes, ctx.currentAttempt);
return events.saveEvents(trxEvents, done);
}
}));
});
}
// function to build fresh promise for transactions routes
const buildNonPrimarySendRequestPromise = (ctx, route, options, path) => sendRequest(ctx, route, options).then(response => {
const routeObj = {};
routeObj.name = route.name;
routeObj.request = {
host: options.hostname,
port: options.port,
path,
headers: ctx.request.header,
querystring: ctx.request.querystring,
method: ctx.request.method,
timestamp: ctx.requestTimestamp
};
if (response.headers != null && response.headers['content-type'] != null && response.headers['content-type'].indexOf('application/json+openhim') > -1) {
// handle mediator reponse
const responseObj = JSON.parse(response.body);
routeObj.mediatorURN = responseObj['x-mediator-urn'];
routeObj.orchestrations = responseObj.orchestrations;
routeObj.properties = responseObj.properties;
if (responseObj.metrics) {
routeObj.metrics = responseObj.metrics;
}
routeObj.response = responseObj.response;
} else {
routeObj.response = response;
}
if (!ctx.routes) {
ctx.routes = [];
}
ctx.routes.push(routeObj);
return routeObj;
}).catch(reason => {
// on failure
const routeObj = {};
routeObj.name = route.name;
handleServerError(ctx, reason, routeObj);
return routeObj;
});
function sendRequest(ctx, route, options) {
function buildOrchestration(response) {
const orchestration = {
name: route.name,
request: {
host: options.hostname,
port: options.port,
path: options.path,
headers: options.headers,
method: options.method,
body: ctx.body,
timestamp: ctx.requestTimestamp
}
};
if (response instanceof Error) {
orchestration.error = {
message: response.message,
stack: response.stack
};
} else {
orchestration.response = {
headers: response.headers,
status: response.status,
body: response.body,
timestamp: response.timestamp
};
}
return orchestration;
}
function recordOrchestration(response) {
if (!route.primary) {
// Only record orchestrations for primary routes
return;
}
if (!Array.isArray(ctx.orchestrations)) {
ctx.orchestrations = [];
}
ctx.orchestrations.push(buildOrchestration(response));
}
if (route.type === 'tcp' || route.type === 'mllp') {
_winston2.default.info('Routing socket request');
return sendSocketRequest(ctx, route, options);
} else {
_winston2.default.info('Routing http(s) request');
return sendHttpRequest(ctx, route, options).then(response => {
recordOrchestration(response);
// Return the response as before
return response;
}).catch(err => {
recordOrchestration(err);
// Rethrow the error
throw err;
});
}
}
function obtainCharset(headers) {
const contentType = headers['content-type'] || '';
const matches = contentType.match(/charset=([^;,\r\n]+)/i);
if (matches && matches[1]) {
return matches[1];
}
return 'utf-8';
}
/*
* A promise returning function that send a request to the given route and resolves
* the returned promise with a response object of the following form:
* response =
* status: <http_status code>
* body: <http body>
* headers: <http_headers_object>
* timestamp: <the time the response was recieved>
*/
function sendHttpRequest(ctx, route, options) {
return new Promise((resolve, reject) => {
const response = {};
const gunzip = _zlib2.default.createGunzip();
const inflate = _zlib2.default.createInflate();
let method = _http2.default;
if (route.secured) {
method = _https2.default;
}
const routeReq = method.request(options, routeRes => {
response.status = routeRes.statusCode;
response.headers = routeRes.headers;
const uncompressedBodyBufs = [];
if (routeRes.headers['content-encoding'] === 'gzip') {
// attempt to gunzip
routeRes.pipe(gunzip);
gunzip.on('data', data => {
uncompressedBodyBufs.push(data);
});
}
if (routeRes.headers['content-encoding'] === 'deflate') {
// attempt to inflate
routeRes.pipe(inflate);
inflate.on('data', data => {
uncompressedBodyBufs.push(data);
});
}
const bufs = [];
routeRes.on('data', chunk => bufs.push(chunk));
// See https://www.exratione.com/2014/07/nodejs-handling-uncertain-http-response-compression/
routeRes.on('end', () => {
response.timestamp = new Date();
const charset = obtainCharset(routeRes.headers);
if (routeRes.headers['content-encoding'] === 'gzip') {
gunzip.on('end', () => {
const uncompressedBody = Buffer.concat(uncompressedBodyBufs);
response.body = uncompressedBody.toString(charset);
resolve(response);
});
} else if (routeRes.headers['content-encoding'] === 'deflate') {
inflate.on('end', () => {
const uncompressedBody = Buffer.concat(uncompressedBodyBufs);
response.body = uncompressedBody.toString(charset);
resolve(response);
});
} else {
response.body = Buffer.concat(bufs);
resolve(response);
}
});
});
routeReq.on('error', err => {
reject(err);
});
routeReq.on('clientError', err => {
reject(err);
});
const timeout = route.timeout != null ? route.timeout : +_config.config.router.timeout;
routeReq.setTimeout(timeout, () => {
routeReq.destroy(new Error(`Request took longer than ${timeout}ms`));
});
if (ctx.request.method === 'POST' || ctx.request.method === 'PUT') {
if (ctx.body != null) {
// TODO : Should probally add checks to see if the body is a buffer or string
routeReq.write(ctx.body);
}
}
routeReq.end();
});
}
/*
* A promise returning function that send a request to the given route using sockets and resolves
* the returned promise with a response object of the following form: ()
* response =
* status: <200 if all work, else 500>
* body: <the received data from the socket>
* timestamp: <the time the response was recieved>
*
* Supports both normal and MLLP sockets
*/
function sendSocketRequest(ctx, route, options) {
return new Promise((resolve, reject) => {
const mllpEndChar = String.fromCharCode(0o034);
const requestBody = ctx.body;
const response = {};
let method = _net2.default;
if (route.secured) {
method = _tls2.default;
}
options = {
host: options.hostname,
port: options.port,
rejectUnauthorized: options.rejectUnauthorized,
key: options.key,
cert: options.cert,
secureProtocol: options.secureProtocol,
ca: options.ca
};
const client = method.connect(options, () => {
_winston2.default.info(`Opened ${route.type} connection to ${options.host}:${options.port}`);
if (route.type === 'tcp') {
return client.end(requestBody);
} else if (route.type === 'mllp') {
return client.write(requestBody);
} else {
return _winston2.default.error(`Unkown route type ${route.type}`);
}
});
const bufs = [];
client.on('data', chunk => {
bufs.push(chunk);
if (route.type === 'mllp' && chunk.toString().indexOf(mllpEndChar) > -1) {
_winston2.default.debug('Received MLLP response end character');
return client.end();
}
});
client.on('error', err => reject(err));
const timeout = route.timeout != null ? route.timeout : +_config.config.router.timeout;
client.setTimeout(timeout, () => {
client.destroy(new Error(`Request took longer than ${timeout}ms`));
});
client.on('end', () => {
_winston2.default.info(`Closed ${route.type} connection to ${options.host}:${options.port}`);
if (route.secured && !client.authorized) {
return reject(new Error('Client authorization failed'));
}
response.body = Buffer.concat(bufs);
response.status = 200;
response.timestamp = new Date();
return resolve(response);
});
});
}
function getDestinationPath(route, requestPath) {
if (route.path) {
return route.path;
} else if (route.pathTransform) {
return transformPath(requestPath, route.pathTransform);
} else {
return requestPath;
}
}
/*
* Applies a sed-like expression to the path string
*
* An expression takes the form s/from/to
* Only the first 'from' match will be substituted
* unless the global modifier as appended: s/from/to/g
*
* Slashes can be escaped as \/
*/
function transformPath(path, expression) {
// replace all \/'s with a temporary ':' char so that we don't split on those
// (':' is safe for substitution since it cannot be part of the path)
let fromRegex;
const sExpression = expression.replace(/\\\//g, ':');
const sub = sExpression.split('/');
const from = sub[1].replace(/:/g, '/');
let to = sub.length > 2 ? sub[2] : '';
to = to.replace(/:/g, '/');
if (sub.length > 3 && sub[3] === 'g') {
fromRegex = new RegExp(from, 'g');
} else {
fromRegex = new RegExp(from);
}
return path.replace(fromRegex, to);
}
/*
* Gets the authorised channel and routes
* the request to all routes within that channel. It updates the
* response of the context object to reflect the response recieved from the
* route that is marked as 'primary'.
*
* Accepts (ctx, next) where ctx is a [Koa](http://koajs.com/) context
* object and next is a callback that is called once the route marked as
* primary has returned an the ctx.response object has been updated to
* reflect the response from that route.
*/
function route(ctx, next) {
const channel = ctx.authorisedChannel;
if (!isMethodAllowed(ctx, channel)) {
next();
} else {
if (channel.timeout != null) {
channel.routes.forEach(route => {
route.timeout = channel.timeout;
});
}
sendRequestToRoutes(ctx, channel.routes, next);
}
}
/**
* Checks if the request in the current context is allowed
*
* @param {any} ctx Koa context, will mutate the response property if not allowed
* @param {any} channel Channel that is getting fired against
* @returns {Boolean}
*/
function isMethodAllowed(ctx, channel) {
const { request: { method } = {} } = ctx || {};
const { methods = [] } = channel || {};
if (utils.isNullOrWhitespace(method) || methods.length === 0) {
return true;
}
const isAllowed = methods.indexOf(method.toUpperCase()) !== -1;
if (!isAllowed) {
_winston2.default.info(`Attempted to use method ${method} with channel ${channel.name} valid methods are ${methods.join(', ')}`);
Object.assign(ctx.response, {
status: 405,
timestamp: new Date(),
body: `Request with method ${method} is not allowed. Only ${methods.join(', ')} methods are allowed`
});
}
return isAllowed;
}
/*
* The [Koa](http://koajs.com/) middleware function that enables the
* router to work with the Koa framework.
*
* Use with: app.use(router.koaMiddleware)
*/
async function koaMiddleware(ctx, next) {
let startTime;
if (statsdServer.enabled) {
startTime = new Date();
}
const _route = (0, _util.promisify)(route);
await _route(ctx);
if (statsdServer.enabled) {
sdc.timing(`${domain}.routerMiddleware`, startTime);
}
await next();
}
//# sourceMappingURL=router.js.map