@m92/express-utils
Version:
Utility Module for Express Framework
411 lines (337 loc) • 12.7 kB
JavaScript
import axios from 'axios'
import axiosRetry from 'axios-retry'
import context from 'express-http-context'
import { transform } from 'camaro'
import { nanoid } from 'nanoid'
import mime from 'mime-types'
import autoBind from 'auto-bind'
import HTTP_CLIENT_CONFIG from '../defaults/HTTP_CLIENT_CONFIG'
import AXIOS_RETRY_CONFIG from '../defaults/AXIOS_RETRY_CONFIG'
export default function HttpClientCreator (CONFIG, CONSTANTS, thisExpressUtils) {
const {
HTTP_CLIENT_LOG_ENABLED,
HTTP_CLIENT_BODY_LOG_ENABLED,
AAUTH_API_KEY
} = CONFIG
const {
UID_CONTEXT,
UID_HEADER_KEY,
SESSION_ID_CONTEXT,
SESSION_ID_HEADER_KEY,
REQUEST_ID_CONTEXT,
REQUEST_ID_HEADER_KEY,
API_KEY_HEADER_KEY,
DEFAULT_SOAP_VERSION
} = CONSTANTS
const { CustomError, logger, ERROR_CLASSIFICATIONS } = thisExpressUtils
return class HttpClient {
constructor (config = {}, overrideHeaders = {}) {
const uid = overrideHeaders.uid || context.get(UID_CONTEXT) || ''
const sessionId = overrideHeaders.sessionId || context.get(SESSION_ID_CONTEXT) || ''
const requestId = overrideHeaders.requestId || context.get(REQUEST_ID_CONTEXT) || ''
const headers = {
[UID_HEADER_KEY]: uid,
[SESSION_ID_HEADER_KEY]: sessionId,
[REQUEST_ID_HEADER_KEY]: requestId
}
const { disableBodyLogging } = config
const customConfig = {
disableBodyLogging: !!disableBodyLogging,
overrideHeaders
}
// retry config
const { retry: retryConfig = {}, ...axiosConfig } = config
const axiosConf = {
...HTTP_CLIENT_CONFIG,
headers,
...axiosConfig
}
this.client = axios.create(axiosConf)
// Request Interceptors
this.client.interceptors.request.use(
// Interceptor for Request Success
HttpClient.requestLoggerSuccess(customConfig),
// Interceptor for Request Failure
HttpClient.requestLoggerFailure(customConfig)
)
// Response Interceptors
this.client.interceptors.response.use(
// Interceptor for Response Success
HttpClient.responseLoggerSuccess(customConfig),
// Interceptor for Response Failure
HttpClient.responseLoggerFailure(customConfig)
)
// axios-retry
const axiosRetryConfig = {
...AXIOS_RETRY_CONFIG,
...retryConfig
}
axiosRetry(this.client, axiosRetryConfig)
autoBind(this)
}
async request (options, errorMap) {
const { client } = this
const { method, url } = options
const classification = ERROR_CLASSIFICATIONS.API_CALL
if (!method) {
const methodErrorMsg = 'HttpClientError: \'method\' cannot be empty'
const methodErrorParams = { statusCode: 400, message: methodErrorMsg, classification }
throw new CustomError(undefined, methodErrorParams)
}
if (!url) {
const urlErrorMsg = 'HttpClientError: \'url\' cannot be empty'
const urlErrorParams = { statusCode: 400, message: urlErrorMsg, classification }
throw new CustomError(undefined, urlErrorParams)
}
const sanitizedOptions = this.sanitizeOptions(options)
return client.request(sanitizedOptions).catch(error => HttpClient.throwError(error, errorMap))
}
async requestSoap (options, errorMap) {
const basicHeaders = {
'Content-Type': 'application/xml'
}
const requestOptions = {
...options,
headers: {
...basicHeaders,
...options.headers
}
}
const { method, url } = requestOptions
const classification = ERROR_CLASSIFICATIONS.API_CALL
if (!method) {
const methodErrorMsg = 'HttpClientError: \'method\' cannot be empty'
const methodErrorParams = { statusCode: 400, message: methodErrorMsg, classification }
throw new CustomError(undefined, methodErrorParams)
}
if (!url) {
const urlErrorMsg = 'HttpClientError: \'url\' cannot be empty'
const urlErrorParams = { statusCode: 400, message: urlErrorMsg, classification }
throw new CustomError(undefined, urlErrorParams)
}
return this.client.request(requestOptions)
.catch(error => HttpClient.throwError(error))
.then(response => HttpClient.throwSoapError(response, errorMap))
}
async requestSoapWithAttachment (options, attachment = {}, errorMap) {
const { headers: optHeader = {}, data: optData = '', soapVersion = DEFAULT_SOAP_VERSION } = options
const { headers, data } = await this._addAttachment(soapVersion, optData, attachment)
const requestHeaders = { ...headers, ...optHeader }
const requestOptions = { ...options, headers: requestHeaders, data }
return this.requestSoap(requestOptions, errorMap)
}
sanitizeOptions (options) {
const { headers, disableApiKey = false } = options
// Handle Header for API Key
const apiKeyHeader = { [API_KEY_HEADER_KEY]: AAUTH_API_KEY }
const sanitizedHeaders = {
...(!disableApiKey && apiKeyHeader),
...headers
}
const sanitizedOptions = { ...options, headers: sanitizedHeaders }
return sanitizedOptions
}
async _addAttachment (soapVersion, soapData, attachment) {
const { file, fileName, type } = attachment
const mimeType = attachment.mimeType || this._getMimeType(fileName)
const { contentType, attachmentEncoding } = this._getSoapVersionBasedProps(soapVersion)
const fileString = await this._getFile(file, type, attachmentEncoding)
const boundary = `----=_Part_1_${nanoid()}`
const startContentId = `<rootpart.${nanoid()}>`
const headers = {
'Content-Type': `multipart/related; type="${contentType}"; start="${startContentId}"; boundary="${boundary}"`,
SoapAction: ''
}
// Code is indent in such fashion due to the use of string interpolation
const startBoundary = `--${boundary}
Content-Type: ${contentType}; charset=UTF-8
Content-Transfer-Encoding: 8bit
Content-ID: ${startContentId}
`
const attachmentData = `--${boundary}
Content-Type: ${mimeType}; name=${fileName}
Content-Transfer-Encoding: ${attachmentEncoding}
Content-ID: <${fileName}-${nanoid()}>
${fileString}`
const endBoundary = `--${boundary}--`
const data = `${startBoundary}
${soapData}
${attachmentData}
${endBoundary}`
return { headers, data }
}
_getSoapVersionBasedProps (soapVersion) {
switch (soapVersion) {
case '1.1': return {
contentType: 'text/xml',
attachmentEncoding: 'base64'
}
case '1.2': return {
contentType: 'application/xop+xml',
attachmentEncoding: 'base64'
}
}
}
_getMimeType (fileName) {
return mime.lookup(fileName)
}
async _getFile (file, type, attachmentEncoding) {
switch (type) {
case 'buffer': {
return file.toString(attachmentEncoding)
}
case 'base64': {
return (attachmentEncoding === 'base64' && file) || Buffer.from(file, 'base64').toString(attachmentEncoding)
}
case 'url': {
const requestOptions = {
method: 'GET',
url: file,
responseType: 'arraybuffer'
}
try {
const response = await this.request(requestOptions)
const { data: body } = response
return (attachmentEncoding === 'binary' && body) || Buffer.from(body, 'binary').toString(attachmentEncoding)
} catch (e) {
const message = `Unable to download file from URL: ${file}`
const classification = ERROR_CLASSIFICATIONS.API_CALL
const errorParams = { message, classification }
const error = Buffer.from(e.error, 'binary').toString('utf8')
throw new CustomError({ ...e, error }, errorParams)
}
}
default: {
const message = `Invalid Soap Attachment Type: ${type}`
const classification = ERROR_CLASSIFICATIONS.VALIDATION
const errorParams = { statusCode: 500, message, classification }
throw new CustomError(null, errorParams)
}
}
}
static async throwError (error, errorMap = {}) {
const { request, response } = error
// Handle Axios Response Error
if (response) {
const { status = 500, data: body } = response
const { statusCode, message, data, error } = body
const statusErrorMap = errorMap[status] || {}
const classification = ERROR_CLASSIFICATIONS.API_CALL
const { name, code, message: errMapMsg, statusCode: errMapStatusCode } = statusErrorMap
const e = (error || ((statusCode && message && data && undefined) || body))
const errorParams = {
statusCode: (errMapStatusCode || statusCode || status),
message: (errMapMsg || message || undefined),
classification,
code,
name,
data
}
const err = new CustomError(e, errorParams, false)
throw err
}
// Handle Axios Request Error
if (request) {
const classification = ERROR_CLASSIFICATIONS.CONNECTION
const { message } = error
const errorParams = {
statusCode: 500,
message,
classification
}
const err = new CustomError(error, errorParams, false)
delete err.error.stack
throw err
}
// Handle any other form of error
const err = new CustomError(error)
throw err
}
static async throwSoapError (response, errorMap) {
if (!errorMap) { return response }
const {
statusKey = '',
messageKey = '',
statusValueMap = {}
} = errorMap
const errorTemplate = {
status: statusKey,
message: messageKey
}
const error = await transform(response, errorTemplate)
const mappedError = statusValueMap[error.status]
if (!mappedError) { return response }
const { statusCode, message } = mappedError
const classification = ERROR_CLASSIFICATIONS.API_CALL
const errorParams = { statusCode, message, classification }
throw new CustomError(error, errorParams, false)
}
static requestLoggerSuccess (customConfig) {
return function (config) {
const isError = false
HttpClient.logRequest(config, isError, customConfig)
return config
}
}
static requestLoggerFailure (customConfig) {
return function (error) {
const isError = true
HttpClient.logRequest(error, isError, customConfig)
return Promise.reject(error)
}
}
static responseLoggerSuccess (customConfig) {
return function (response) {
const isError = false
HttpClient.logResponse(response, isError, customConfig)
return response
}
}
static responseLoggerFailure (customConfig) {
return function (error) {
const isError = true
HttpClient.logResponse(error, isError, customConfig)
return Promise.reject(error)
}
}
static logRequest (config, isError, customConfig) {
if (!HTTP_CLIENT_LOG_ENABLED) { return }
const { config: axiosConfig } = config
const { method, url, data: body } = axiosConfig || config
const { disableBodyLogging, overrideHeaders = {} } = customConfig
const logMsg = `HttpClientRequest | ${method} ${url}`
const logData = {
url,
method,
body: ((HTTP_CLIENT_BODY_LOG_ENABLED && !disableBodyLogging && body) || {}),
...overrideHeaders
}
const logFunction = isError
? logger.error
: logger.trace
logFunction(logMsg, logData)
}
static logResponse (response, isError, customConfig) {
if (!HTTP_CLIENT_LOG_ENABLED) { return }
const { config = {}, code = '', response: axiosResponse } = response
const { status = 500, statusText = '', data: body } = axiosResponse || response
const { method = '', url = '' } = config
const { disableBodyLogging, overrideHeaders = {} } = customConfig
const logMsg = `HttpClientResponse | ${method} ${url} | ${status} ${code || statusText}`
const logData = {
url,
method,
code,
status,
statusText,
body: ((HTTP_CLIENT_BODY_LOG_ENABLED && !disableBodyLogging && body) || {}),
...overrideHeaders
}
const logFunction = isError
? logger.error
: logger.info
logFunction(logMsg, logData)
}
}
}