UNPKG

openhim-core

Version:

The OpenHIM core application that provides logging and routing of http requests

772 lines (622 loc) 22.4 kB
"use strict"; 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