6-mils
Version:
A JS library for sending, receiving, and parsing cXML messages.
402 lines (334 loc) • 11.4 kB
JavaScript
const debug = require('debug')('6-mils:OutboundCxmlMessage')
const { DateTime } = require('luxon')
const EventEmitter = require('events')
const isPlainObject = require('is-plain-obj')
const merge = require('lodash.merge')
const nunjucks = require('nunjucks')
const path = require('path')
const request = require('got')
const cxmlVersion = require('@6-mils/CxmlVersion')
const getNewPayloadId = require('@6-mils/CxmlPayloadId')
const packageInfo = require('../../../../package.json')
const templateEnvironment = nunjucks.configure(path.join(__dirname, './templates'))
/**
* The default time (in milliseconds) to wait for a response after submitting
* the cXML request.
* @type {Number}
*/
const DEFAULT_REQUEST_TIMEOUT = 30 * 1000
/**
* This enumeration is static, so it is defined outside of the class.
* @type {Object}
*/
const messageTypeEnum = Object.freeze({
/**
* For unit test purposes only. Any outbound message with this type cannot be
* sent to any supplier.
*/
test: Symbol('test'),
/**
* For requesting a new PunchOut session.
*/
PunchOutSetupRequest: Symbol('PunchOutSetupRequest'),
/**
* For sending electronic purchase orders.
*/
OrderRequest: Symbol('OrderRequest')
})
/**
* A collection of private property values for each instance of this class.
* @type {WeakMap}
*/
const _private = new WeakMap()
/**
* Merges `src` into `dest`, after converting all of the values in `src` into
* strings. Does not modify `src` or `dest`.
* @return {Object}
*/
function stringifyAndMerge (src, dest) {
const result = {}
merge(result, dest) // result is now a copy of dest
if (src != null) {
Object.keys(src).forEach((key) => {
if (src[key] == null) { throw new TypeError('Null or undefined values not allowed.') }
result[key] = src[key].toString()
})
}
return result
}
/**
* This base class is intended to provide the basic functionality for creating
* and sending messages to a supplier.
*
* @param {Symbol} type A value from MESSAGE_TYPES.
*
* Options:
*
* {String?} payloadId An optional value to be inserted into the "payloadID"
* attribute of the "<cXML>" element.
* If this value is empty or missing, a random
* identifier plus the host "@unknown" will be used.
* If this value starts with the "@" symbol, a random
* identifier plus the given value will be used.
* If it begins with any other character, the value will
* be used as-is.
*
* {String} template The name of the template to use to render the cXML
* for this message.
*/
class OutboundCxmlMessage extends EventEmitter {
constructor (type, options) {
super()
type = type || {}
const symbolDescriptionMatches = /^Symbol\((\w+)\)$/.exec(type.toString())
if (symbolDescriptionMatches == null || !~Object.keys(messageTypeEnum).indexOf(symbolDescriptionMatches[1])) {
throw new Error('The constructor for OutboundCxmlMessage reqiures a value from OutboundCxmlMessage.MESSAGE_TYPES.')
}
Object.defineProperty(this, '_type', { value: symbolDescriptionMatches[1], writable: false })
options = options || {}
options.payloadId = (options.payloadId || '').toString()
if (options.payloadId.length === 0 || options.payloadId.startsWith('@')) {
options.payloadId = getNewPayloadId(options.payloadId)
}
options.userAgent = options.userAgent || `6-mils@${packageInfo.version}`
// Populate the default values for each private property.
_private.set(this, {
extrinsic: {},
from: { domain: '', id: '' },
payloadId: options.payloadId,
sender: { domain: '', id: '', secret: '', ua: options.userAgent },
timestamp: '',
to: { domain: '', id: '' },
version: cxmlVersion,
requestTimeout: DEFAULT_REQUEST_TIMEOUT
})
}
static get MESSAGE_TYPES () {
return messageTypeEnum
}
get payloadId () {
return _private.get(this).payloadId
}
get requestTimeout () {
return _private.get(this).requestTimeout
}
set requestTimeout (newValue) {
if (!Number.isInteger(newValue) || newValue < 1) {
throw new Error('The value for "requestTimeout" must be a positive integer.')
}
const baseProps = _private.get(this)
baseProps.requestTimeout = newValue
_private.set(this, baseProps)
}
get timestamp () {
return _private.get(this).timestamp
}
get version () {
return _private.get(this).version
}
/**
* Populates the <From> element in the request envelope.
*
* @param {Object} options A dictionary with keys "domain" and "id".
*
* @return this
*/
setBuyerInfo (options) {
options = options || {}
if (!isPlainObject(options)) {
throw new Error('The "options" parameter, if provided, must be a plain object.')
}
const baseProps = _private.get(this)
try {
baseProps.from = stringifyAndMerge(options, baseProps.from)
} catch (e) {
throw new Error('Cannot provide null or undefined values to "setBuyerInfo". Please make sure you are passing valid data for both "domain" and "id", or simply exclude those which do not need to be updated.')
}
_private.set(this, baseProps)
return this
}
/**
* Populates the <Extrinsic> element(s) in the request body.
*
* @param {Object} hash A dictionary of key-value pairs that will be used
* to generate the "<Extrinsic>" elements.
*
* @return this
*/
setExtrinsic (hash) {
hash = hash || {}
if (!isPlainObject(hash)) {
throw new Error('The "hash" parameter, if provided, must be a plain object.')
}
const props = _private.get(this)
props.extrinsics = hash
_private.set(this, props)
return this
}
/**
* Populates the <To> element in the request envelope.
*
* @param {Object} options A dictionary with keys "domain" and "id".
*
* @return this
*/
setSupplierInfo (options) {
options = options || {}
if (!isPlainObject(options)) {
throw new Error('The "options" parameter, if provided, must be a plain object.')
}
const baseProps = _private.get(this)
try {
baseProps.to = stringifyAndMerge(options, baseProps.to)
} catch (e) {
throw new Error('Cannot provide null or undefined values to "setSupplierInfo". Please make sure you are passing valid data for both "domain" and "id", or simply exclude those which do not need to be updated.')
}
_private.set(this, baseProps)
return this
}
/**
* Populates the <Sender> element in the request envelope.
*
* @param {Object} options A dictionary with keys "domain", "id",
* "secret", and "ua".
*
* @return this
*/
setSenderInfo (options) {
options = options || {}
if (!isPlainObject(options)) {
throw new Error('The "options" parameter, if provided, must be a plain object.')
}
const baseProps = _private.get(this)
try {
baseProps.sender = stringifyAndMerge(options, baseProps.sender)
} catch (e) {
throw new Error('Cannot assign null or undefined values in "setSenderInfo". Please make sure you are passing valid data for "domain", "id", and "secret", or simply exclude those which do not need to be updated.')
}
_private.set(this, baseProps)
return this
}
/**
* Returns the raw cXML message represented by this class.
*
* @param {Object} props A collection of private properties held by
* the sub-class instance.
*
* @param {Boolean?} format A flag indicating if the XML should be
* prettified.
*
* @return {String}
*/
_renderCxml (props, format) {
const baseProps = _private.get(this)
if (baseProps.sender.ua.length === 0) {
baseProps.sender.ua = `6-mils@${packageInfo.version}`
_private.set(this, baseProps)
}
const cxml = templateEnvironment.render(this._type, merge(props, baseProps))
/**
* The template is assumed to be properly formatted to begin with. If no
* formatting is desired, then any whitespace that exists between tags is
* removed.
*/
return (format ? cxml : cxml.replace(/>\s+</g, '><'))
}
/**
* Sends the cXML message to the specified server.
*
* @param {String} url The address to send the cXML message to.
*
* @param {Object} props A collection of private properties held by the
* sub-class instance.
*
* @return {Promise}
*/
_submitCxml (url, props) {
/**
* This reference is needed to emit the "received" event from within the
* Promise.
*/
const self = this
/**
* The provided URL, parsed as an object. This is necessary because
* otherwise `got` will not pay any attention to embedded port numbers.
* @type {URL}
*/
const parsedUrl = new URL(url)
debug('Sending HTTP request to %s...', parsedUrl.href)
/**
* Update timestamp. This should only be done immediately before the message
* is sent.
*/
const baseProps = _private.get(self)
baseProps.timestamp = DateTime.local().toString()
_private.set(self, baseProps)
debug('(timestamp set to %s)', baseProps.timestamp)
const requestBody = self._renderCxml(props)
self.emit('sending', requestBody)
return request
.post(
parsedUrl,
{
body: requestBody,
headers: {
'content-type': 'application/xml',
'user-agent': baseProps.sender.ua
},
timeout: baseProps.requestTimeout
}
)
.then(function (response) {
debug('...response received in %d ms', response.timings.phases.total)
self.emit('received', response.body)
if (response.body.length === 0) {
return '%%EMPTY%%'
}
return response.body
})
.catch(function (err) {
debug('...connection error: %s', (err.code || err.statusMessage))
throw err
})
}
/**
* Returns a generic response to an outgoing request (to support testing).
* @return {dynamic}
*/
_getGenericResponse () {
switch (this._type) {
case 'PunchOutSetupRequest':
return templateEnvironment.render(
'PunchOutSetupResponse',
{
language: 'en',
payloadID: getNewPayloadId(),
timestamp: DateTime.local().toString(),
version: cxmlVersion,
status: {
code: '200',
shortName: 'OK',
description: ''
},
url: 'http://example.org/punchout'
}
)
case 'OrderRequest':
return templateEnvironment.render(
'OrderResponse',
{
language: 'en',
payloadID: getNewPayloadId(),
timestamp: DateTime.local().toString(),
version: cxmlVersion,
status: {
code: '200',
shortName: 'OK',
description: ''
}
}
)
}
}
}
module.exports = OutboundCxmlMessage