openhim-core
Version:
The OpenHIM core application that provides logging and routing of http requests
488 lines (402 loc) • 15.4 kB
text/coffeescript
util = require 'util'
zlib = require 'zlib'
http = require 'http'
https = require 'https'
net = require 'net'
tls = require 'tls'
Q = require 'q'
config = require '../config/config'
config.mongo = config.get 'mongo'
config.router = config.get 'router'
logger = require 'winston'
cookie = require 'cookie'
fs = require 'fs'
utils = require '../utils'
messageStore = require '../middleware/messageStore'
events = require '../middleware/events'
stats = require "../stats"
statsdServer = config.get 'statsd'
application = config.get 'application'
SDC = require 'statsd-client'
os = require 'os'
domain = "#{os.hostname()}.#{application.name}.appMetrics"
sdc = new SDC statsdServer
isRouteEnabled = (route) -> not route.status? or route.status is 'enabled'
exports.numberOfPrimaryRoutes = numberOfPrimaryRoutes = (routes) ->
numPrimaries = 0
for route in routes
numPrimaries++ if isRouteEnabled(route) and route.primary
return numPrimaries
containsMultiplePrimaries = (routes) -> numberOfPrimaryRoutes(routes) > 1
setKoaResponse = (ctx, response) ->
# Try and parse the status to an int if it is a string
if typeof response.status is 'string'
try
response.status = parseInt response.status
catch err
logger.error err
ctx.response.status = response.status
ctx.response.timestamp = response.timestamp
ctx.response.body = response.body
if not ctx.response.header
ctx.response.header = {}
if ctx.request?.header?["X-OpenHIM-TransactionID"]
if response?.headers?
response.headers["X-OpenHIM-TransactionID"] = ctx.request.header["X-OpenHIM-TransactionID"]
for key, value of response.headers
switch key.toLowerCase()
when 'set-cookie' then setCookiesOnContext ctx, value
when 'location'
if response.status >= 300 and response.status < 400
ctx.response.redirect value
else
ctx.response.set key, value
when 'content-type' then ctx.response.type = value
else
try
# Strip the content and transfer encoding headers
if key != 'content-encoding' and key != 'transfer-encoding'
ctx.response.set key, value
catch err
logger.error err
if process.env.NODE_ENV == "test"
exports.setKoaResponse = setKoaResponse
setCookiesOnContext = (ctx, value) ->
logger.info 'Setting cookies on context'
for c_key,c_value in value
c_opts = {path:false,httpOnly:false} #clear out default values in cookie module
c_vals = {}
for p_key,p_val of cookie.parse c_key
p_key_l = p_key.toLowerCase()
switch p_key_l
when 'max-age' then c_opts['maxage'] = parseInt p_val, 10
when 'expires' then c_opts['expires'] = new Date p_val
when 'path','domain','secure','signed','overwrite' then c_opts[p_key_l] = p_val
when 'httponly' then c_opts['httpOnly'] = p_val
else c_vals[p_key] = p_val
for p_key,p_val of c_vals
ctx.cookies.set p_key,p_val,c_opts
handleServerError = (ctx, err, route) ->
ctx.autoRetry = true
if route
route.error =
message: err.message
stack: err.stack if err.stack
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 if err.stack
logger.error "[#{ctx.transactionId?.toString()}] Internal server error occured: #{err}"
logger.error "#{err.stack}" if err.stack
sendRequestToRoutes = (ctx, routes, next) ->
promises = []
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"
utils.getKeystore (err, keystore) ->
for route in routes
do (route) ->
if not isRouteEnabled route then return #continue
path = getDestinationPath route, ctx.path
options =
hostname: route.host
port: route.port
path: 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?
options.ca = keystore.ca.id(route.cert).data
if ctx.request.querystring
options.path += '?' + ctx.request.querystring
if options.headers and options.headers.authorization and not route.forwardAuthHeader
delete options.headers.authorization
if route.username and 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) ->
logger.info "executing primary route : #{route.name}"
if response.headers?['content-type']?.indexOf('application/json+openhim') > -1
# handle mediator reponse
responseObj = JSON.parse response.body
ctx.mediatorResponse = responseObj
if responseObj.error?
ctx.autoRetry = true
ctx.error = responseObj.error
# then set koa response from responseObj.response
setKoaResponse ctx, responseObj.response
else
setKoaResponse ctx, response
.then ->
logger.info "primary route completed"
next()
.fail (reason) ->
# on failure
handleServerError ctx, reason
next()
else
logger.info "executing non primary: #{route.name}"
promise = buildNonPrimarySendRequestPromise(ctx, route, options, path)
.then (routeObj) ->
logger.info "Storing non primary route responses #{route.name}"
try
if not routeObj?.name?
routeObj =
name: route.name
if not routeObj?.response?
routeObj.response =
status: 500
timestamp: ctx.requestTimestamp
if not routeObj?.request?
routeObj.request =
host: options.hostname
port: options.port
path: path
headers: ctx.request.header
querystring: ctx.request.querystring
method: ctx.request.method
timestamp: ctx.requestTimestamp
messageStore.storeNonPrimaryResponse ctx, routeObj, ->
stats.nonPrimaryRouteRequestCount ctx, routeObj, ->
stats.nonPrimaryRouteDurations ctx, routeObj, ->
catch err
logger.error err
promises.push promise
(Q.all promises).then ->
messageStore.setFinalStatus ctx, ->
logger.info "All routes completed for transaction: #{ctx.transactionId.toString()}"
if ctx.routes
logger.debug "Storing route events for transaction: #{ctx.transactionId}"
done = (err) -> logger.error err if err
trxEvents = []
events.createSecondaryRouteEvents trxEvents, ctx.transactionId, ctx.requestTimestamp, ctx.authorisedChannel, ctx.routes, ctx.currentAttempt
events.saveEvents trxEvents, done
# function to build fresh promise for transactions routes
buildNonPrimarySendRequestPromise = (ctx, route, options, path) ->
sendRequest ctx, route, options
.then (response) ->
routeObj = {}
routeObj.name = route.name
routeObj.request =
host: options.hostname
port: options.port
path: path
headers: ctx.request.header
querystring: ctx.request.querystring
method: ctx.request.method
timestamp: ctx.requestTimestamp
if response.headers?['content-type']?.indexOf('application/json+openhim') > -1
# handle mediator reponse
responseObj = JSON.parse response.body
routeObj.mediatorURN = responseObj['x-mediator-urn']
routeObj.orchestrations = responseObj.orchestrations
routeObj.properties = responseObj.properties
routeObj.metrics = responseObj.metrics if responseObj.metrics
routeObj.response = responseObj.response
else
routeObj.response = response
ctx.routes = [] if not ctx.routes
ctx.routes.push routeObj
return routeObj
.fail (reason) ->
# on failure
routeObj = {}
routeObj.name = route.name
handleServerError ctx, reason, routeObj
return routeObj
sendRequest = (ctx, route, options) ->
if route.type is 'tcp' or route.type is 'mllp'
logger.info 'Routing socket request'
return sendSocketRequest ctx, route, options
else
logger.info 'Routing http(s) request'
return sendHttpRequest ctx, route, options
obtainCharset = (headers) ->
contentType = headers['content-type'] || ''
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>
###
sendHttpRequest = (ctx, route, options) ->
defered = Q.defer()
response = {}
gunzip = zlib.createGunzip()
inflate = zlib.createInflate()
method = http
if route.secured
method = https
routeReq = method.request options, (routeRes) ->
response.status = routeRes.statusCode
response.headers = routeRes.headers
uncompressedBodyBufs = []
if routeRes.headers['content-encoding'] == 'gzip' #attempt to gunzip
routeRes.pipe gunzip
gunzip.on "data", (data) ->
uncompressedBodyBufs.push data
return
if routeRes.headers['content-encoding'] == 'deflate' #attempt to inflate
routeRes.pipe inflate
inflate.on "data", (data) ->
uncompressedBodyBufs.push data
return
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()
charset = obtainCharset(routeRes.headers)
if routeRes.headers['content-encoding'] == 'gzip'
gunzip.on "end", ->
uncompressedBody = Buffer.concat uncompressedBodyBufs
response.body = uncompressedBody.toString charset
if not defered.promise.isRejected()
defered.resolve response
return
else if routeRes.headers['content-encoding'] == 'deflate'
inflate.on "end", ->
uncompressedBody = Buffer.concat uncompressedBodyBufs
response.body = uncompressedBody.toString charset
if not defered.promise.isRejected()
defered.resolve response
return
else
response.body = Buffer.concat bufs
if not defered.promise.isRejected()
defered.resolve response
routeReq.on "error", (err) -> defered.reject err
routeReq.on "clientError", (err) -> defered.reject err
routeReq.setTimeout +config.router.timeout, -> defered.reject "Request Timed Out"
if ctx.request.method == "POST" || ctx.request.method == "PUT"
routeReq.write ctx.body
routeReq.end()
return defered.promise
###
# 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
###
sendSocketRequest = (ctx, route, options) ->
mllpEndChar = String.fromCharCode(0o034)
defered = Q.defer()
requestBody = ctx.body
response = {}
method = net
if route.secured
method = tls
options =
host: options.hostname
port: options.port
rejectUnauthorized: options.rejectUnauthorized
key: options.key
cert: options.cert
secureProtocol: options.secureProtocol
ca: options.ca
client = method.connect options, ->
logger.info "Opened #{route.type} connection to #{options.host}:#{options.port}"
if route.type is 'tcp'
client.end requestBody
else if route.type is 'mllp'
client.write requestBody
else
logger.error "Unkown route type #{route.type}"
bufs = []
client.on 'data', (chunk) ->
bufs.push chunk
if route.type is 'mllp' and chunk.toString().indexOf(mllpEndChar) > -1
logger.debug 'Received MLLP response end character'
client.end()
client.on 'error', (err) -> defered.reject err
client.on 'clientError', (err) -> defered.reject err
client.on 'end', ->
logger.info "Closed #{route.type} connection to #{options.host}:#{options.port}"
if route.secured and not client.authorized
return defered.reject new Error 'Client authorization failed'
response.body = Buffer.concat bufs
response.status = 200
response.timestamp = new Date()
if not defered.promise.isRejected()
defered.resolve response
return defered.promise
getDestinationPath = (route, requestPath) ->
if route.path
route.path
else if route.pathTransform
transformPath requestPath, route.pathTransform
else
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 \/
###
exports.transformPath = 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)
sExpression = expression.replace /\\\//g, ':'
sub = sExpression.split '/'
from = sub[1].replace /:/g, '\/'
to = if sub.length > 2 then sub[2] else ""
to = to.replace /:/g, '\/'
if sub.length > 3 and sub[3] is 'g'
fromRegex = new RegExp from, 'g'
else
fromRegex = new RegExp from
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.
###
exports.route = (ctx, next) ->
channel = ctx.authorisedChannel
sendRequestToRoutes ctx, channel.routes, next
###
# The [Koa](http://koajs.com/) middleware function that enables the
# router to work with the Koa framework.
#
# Use with: app.use(router.koaMiddleware)
###
exports.koaMiddleware = (next) ->
startTime = new Date() if statsdServer.enabled
route = Q.denodeify exports.route
yield route this
sdc.timing "#{domain}.routerMiddleware", startTime if statsdServer.enabled
yield next