openhim-core
Version:
The OpenHIM core application that provides logging and routing of http requests
772 lines (622 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 = _interopRequireDefault(require("zlib"));
var _http = _interopRequireDefault(require("http"));
var _https = _interopRequireDefault(require("https"));
var _net = _interopRequireDefault(require("net"));
var _tls = _interopRequireDefault(require("tls"));
var _winston = _interopRequireDefault(require("winston"));
var _cookie = _interopRequireDefault(require("cookie"));
var _config = require("../config");
var utils = _interopRequireWildcard(require("../utils"));
var messageStore = _interopRequireWildcard(require("../middleware/messageStore"));
var events = _interopRequireWildcard(require("../middleware/events"));
var _util = require("util");
function _getRequireWildcardCache() { if (typeof WeakMap !== "function") return null; var cache = new WeakMap(); _getRequireWildcardCache = function () { return cache; }; return cache; }
function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } 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 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;
_winston.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) {
_winston.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 = _cookie.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
};
}
_winston.default.error(`[${ctx.transactionId != null ? ctx.transactionId.toString() : undefined}] Internal server error occured: ${err}`);
if (err.stack) {
return _winston.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
};
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 => {
_winston.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(() => {
_winston.default.info('primary route completed');
return next();
}).catch(reason => {
// on failure
handleServerError(ctx, reason);
return next();
});
} else {
_winston.default.info(`executing non primary: ${route.name}`);
promise = buildNonPrimarySendRequestPromise(ctx, route, options, path).then(routeObj => {
_winston.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, () => {});
} catch (err) {
return _winston.default.error(err);
}
});
}
promises.push(promise);
}
Promise.all(promises).then(() => {
_winston.default.info(`All routes completed for transaction: ${ctx.transactionId}`); // Set the final status of the transaction
messageStore.setFinalStatus(ctx, err => {
if (err) {
_winston.default.error(`Setting final status failed for transaction: ${ctx.transactionId}`, err);
return;
}
_winston.default.debug(`Set final status for transaction: ${ctx.transactionId}`);
}); // Save events for the secondary routes
if (ctx.routes) {
const trxEvents = [];
events.createSecondaryRouteEvents(trxEvents, ctx.transactionId, ctx.requestTimestamp, ctx.authorisedChannel, ctx.routes, ctx.currentAttempt);
events.saveEvents(trxEvents, err => {
if (err) {
_winston.default.error(`Saving route events failed for transaction: ${ctx.transactionId}`, err);
return;
}
_winston.default.debug(`Saving route events succeeded for transaction: ${ctx.transactionId}`);
});
}
}).catch(err => {
_winston.default.error(err);
});
});
} // 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') {
_winston.default.info('Routing socket request');
return sendSocketRequest(ctx, route, options);
} else {
_winston.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 = _zlib.default.createGunzip();
const inflate = _zlib.default.createInflate();
let method = _http.default;
if (route.secured) {
method = _https.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 = _net.default;
if (route.secured) {
method = _tls.default;
}
options = {
host: options.hostname,
port: options.port,
rejectUnauthorized: options.rejectUnauthorized,
key: options.key,
cert: options.cert,
ca: options.ca
};
const client = method.connect(options, () => {
_winston.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 _winston.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) {
_winston.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', () => {
_winston.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) {
_winston.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) {
const _route = (0, _util.promisify)(route);
await _route(ctx);
await next();
}
//# sourceMappingURL=router.js.map