/*
* Copyright (c) 2016, Salesforce.com, Inc.
* All rights reserved.
*
* Legal Text is available at https://github.com/forcedotcom/Legal/blob/master/License.txt
*/
'use strict';
var version = require('../package.json').version;
var helpers = require('./helpers');
var request = require('request');
var xml2js = require('xml2js');
var FuelAuth = require('fuel-auth');
var clone = require('lodash.clone');
var isEmpty = require('lodash.isempty');
var isPlainObject = require('lodash.isplainobject');
var merge = require('lodash.merge');
var parseString = xml2js.parseString;
/**
* @constructor FuelSoap
* @param {Object} options - Configuration options
* @param {Object} options.auth - Object containing information for auth client to initialize
* @param {Object} [options.headers] - Object key/value pairs will add headers every request.
* @param {String} [options.soapEndpoint=https://webservice.exacttarget.com/Service.asmx] - URL for designated SOAP web service
* @returns {FuelSoap}
*/
var FuelSoap = function(options) {
var authOptions = options && options.auth || {};
// use fuel auth instance if applicable
if(authOptions instanceof FuelAuth) {
this.AuthClient = authOptions;
} else {
try {
this.AuthClient = new FuelAuth(authOptions);
} catch (err) {
throw err;
}
}
this.version = version;
this.requestOptions = {};
this.requestOptions.uri = options.soapEndpoint || 'https://webservice.exacttarget.com/Service.asmx';
this.requestOptions.method = 'POST';
this.defaultHeaders = merge({
'User-Agent': 'node-fuel/' + this.version
, 'Content-Type': 'text/xml'
}, options.headers);
};
/**
* This method handles the heavy lifing and is used by other SOAP Actions
* @memberof FuelSoap
* @param {Object} options - Configuration options
* @param {String} options.action - Value that will be used as SOAPAction header
* @param {Object} options.req - SOAP body to be sent prior to building the envelope
* @param {Object} [options.reqOptions] - Options that will be passed into request module (actual API request)
* @param {Object} [options.auth] - Options that will be passed to FuelAuth's getAccessToken function
* @param {Boolean} [options.retry=false] - Whether or not request will retry if token is invalid
* @param {FuelSoap~StandardCallback} callback - Callback that handles response
*/
FuelSoap.prototype.soapRequest = function(options, callback) {
var requestOptions;
if(typeof callback !== 'function') {
throw new TypeError('callback argument is required');
}
if(!isPlainObject(options)) {
throw new TypeError('options argument is required');
}
// shoudl probably replace with object.assign down the road
requestOptions = merge(
{}
, this.requestOptions
, { headers: this.defaultHeaders }
, (options.reqOptions || {})
);
requestOptions.headers.SOAPAction = options.action;
this.AuthClient.getAccessToken(clone(options.auth), function(err, body) {
var localError, retry, authOptions;
if(err) {
callback(err, null);
return;
}
if(!body.accessToken) {
localError = new Error('No access token');
localError.res = body;
callback(localError, null);
return;
}
retry = options.retry || false;
authOptions = clone(options.auth);
delete options.retry;
delete options.auth;
requestOptions.body = this._buildEnvelope(options.req, body.accessToken);
request(requestOptions, function(err, res, body) {
if(err) {
callback(err, null);
return;
}
this._parseResponse(options.key, body, function(err, data) {
if(err && helpers.checkExpiredToken(err) && retry) {
options.auth = authOptions;
this.soapRequest(options, callback);
return;
}
if(err) {
callback(err, null);
} else {
callback(null, { body: data, res: res });
}
}.bind(this));
}.bind(this));
}.bind(this));
};
/**
* This method handles the Create SOAP Action
* @memberof FuelSoap
* @param {String} type - xsi:type
* @param {Object} props - Value set in body as `CreateRequest.Objects`
* @param {Object} [options] - Configuration options passed in body as `CreateRequest.Options`
* @param {Boolean} [options.queryAllAccounts=false] - Sets `QueryAllAccounts = true` to body under `CreateRequest.Options`. **Note:** This value will be delete from body if used
* @param {Object} [options.reqOptions] - Request options passed to soapRequest fn. **Note:** These will be delete from body if passed
* @param {FuelSoap~StandardCallback} callback - Callback that handles response
*/
FuelSoap.prototype.create = function(type, props, options, callback) {
var body;
var reqOptions;
var updateQueryAllAccounts;
var optionsAndCallback;
optionsAndCallback = determineCallbackAndOptions(arguments, callback, options);
callback = optionsAndCallback.callback;
options = optionsAndCallback.options;
updateQueryAllAccounts = configureQueryAllAccounts(options);
if(isEmpty(options)) {
options = null;
}
reqOptions = helpers.parseReqOptions(options);
body = {
CreateRequest: {
$: {
xmlns: 'http://exacttarget.com/wsdl/partnerAPI'
}
, Options: options
, Objects: props
}
};
body.CreateRequest.Objects.$ = { 'xsi:type': type };
updateQueryAllAccounts(body.CreateRequest, 'Options');
this.soapRequest({
action: 'Create'
, req: body
, key: 'CreateResponse'
, retry: true
, reqOptions: reqOptions
}, callback);
};
/**
* This method handles the Retrieve SOAP Action
* It should be noted that type and callback are the only params required.
* If **3 params** exist, function looks like `function(type, options, callback)`.
* If **2 params** exist, function looks like `function(type, callback)`.
* @memberof FuelSoap
* @param {String} type - Will be used in body as `ObjectType` under `RetrieveRequestMsg.RetrieveRequest`
* @param {Object} [props=['Client', 'ID', 'ObjectID']] - Value set in body as `RetrieveRequestMsg.RetrieveRequest.Properties`
* @param {Object} [options] - Configuration options
* @param {Object} [options.reqOptions] - Request options passed to soapRequest fn. **Note:** These will be delete from body if passed
* @param [options.clientIDs] - Will be used in body as `ClientIDs` under `RetrieveRequestMsg.RetrieveRequest`
* @param [options.filter] - Will be used in body as `Filter` under `RetrieveRequestMsg.RetrieveRequest`
* @param [options.continueRequest] - Will be used in body as `ContinueRequest` under `RetrieveRequestMsg.RetrieveRequest`
* @param {FuelSoap~StandardCallback} callback - Callback that handles response
*/
FuelSoap.prototype.retrieve = function(type, props, options, callback) {
var body;
var clientIDs = null;
var continueReq = null;
var defaultProps = ['Client', 'ID', 'ObjectID'];
var filter = null;
var reqOptions;
var updateQueryAllAccounts;
if(arguments.length < 4) {
//if props and options are not included
if(typeof arguments[1] === 'function') {
callback = props;
clientIDs = null;
filter = null;
options = null;
props = defaultProps;
}
//if props or options is included
if(typeof arguments[2] === 'function') {
callback = options;
//check if props or filter
if(isPlainObject(arguments[1])) {
clientIDs = options.clientIDs; // this should really be props. thinking about removing all the complexity with different parameter ordering
continueReq = options.continueRequest || props.continueRequest;
filter = options.filter; // this should really be props
props = defaultProps;
} else {
clientIDs = null;
filter = null;
options = null;
}
}
} else {
clientIDs = options.clientIDs;
continueReq = options.continueRequest;
filter = options.filter;
}
updateQueryAllAccounts = configureQueryAllAccounts(options);
reqOptions = helpers.parseReqOptions(options);
body = {
RetrieveRequestMsg: {
$: {
xmlns: 'http://exacttarget.com/wsdl/partnerAPI'
}
, RetrieveRequest: {
ObjectType: type
, Properties: props
}
}
};
//TO-DO How to handle casing with properties?
if(clientIDs) {
body.RetrieveRequestMsg.RetrieveRequest.ClientIDs = clientIDs;
}
// filter can be simple or complex and has three properties leftOperand, rightOperand, and operator
if(filter) {
body.RetrieveRequestMsg.RetrieveRequest.Filter = this._parseFilter(filter);
}
updateQueryAllAccounts(body.RetrieveRequestMsg, 'RetrieveRequest');
if(continueReq) {
body.RetrieveRequestMsg.RetrieveRequest.ContinueRequest = continueReq;
}
this.soapRequest({
action: 'Retrieve'
, req: body
, key: 'RetrieveResponseMsg'
, retry: true
, reqOptions: reqOptions
}, callback);
};
/**
* This method handles the Update SOAP Action
* @memberof FuelSoap
* @param {String} type - xsi:type
* @param {Object} props - Value set in body as `UpdateRequest.Objects`
* @param {Object} [options] - Configuration options passed in body as `UpdateRequest.Options`
* @param {Boolean} [options.queryAllAccounts=false] - Sets `QueryAllAccounts = true` to body under `UpdateRequest.Options`. **Note:** This value will be delete from body if used
* @param {Object} [options.reqOptions] - Request options passed to soapRequest fn. **Note:** These will be delete from body if passed
* @param {FuelSoap~StandardCallback} callback - Callback that handles response
*/
FuelSoap.prototype.update = function(type, props, options, callback) {
var body;
var optionsAndCallback;
var reqOptions;
var updateQueryAllAccounts;
optionsAndCallback = determineCallbackAndOptions(arguments, callback, options);
callback = optionsAndCallback.callback;
options = optionsAndCallback.options;
updateQueryAllAccounts = configureQueryAllAccounts(options);
if(isEmpty(options)) {
options = null;
}
reqOptions = helpers.parseReqOptions(options);
body = {
UpdateRequest: {
$: {
xmlns: 'http://exacttarget.com/wsdl/partnerAPI'
}
, Options: options
, Objects: props
}
};
body.UpdateRequest.Objects.$ = { 'xsi:type': type };
updateQueryAllAccounts(body.UpdateRequest, 'Options');
this.soapRequest({
action: 'Update'
, req: body
, key: 'UpdateResponse'
, retry: true
, reqOptions: reqOptions
}, callback);
};
/**
* This method handles the Delete SOAP Action
* @memberof FuelSoap
* @param {String} type - xsi:type
* @param {Object} props - Value set in body as `DeleteRequest.Objects`
* @param {Object} [options] - Configuration options passed in body as `DeleteRequest.Options`
* @param {Boolean} [options.queryAllAccounts=false] - Sets `QueryAllAccounts = true` to body under `DeleteRequest.Options`. **Note:** This value will be delete from body if used
* @param {Object} [options.reqOptions] - Request options passed to soapRequest fn. **Note:** These will be delete from body if passed
* @param {FuelSoap~StandardCallback} callback - Callback that handles response
*/
FuelSoap.prototype.delete = function(type, props, options, callback) {
var body;
var optionsAndCallback;
var reqOptions;
var updateQueryAllAccounts;
optionsAndCallback = determineCallbackAndOptions(arguments, callback, options);
callback = optionsAndCallback.callback;
options = optionsAndCallback.options;
updateQueryAllAccounts = configureQueryAllAccounts(options);
if(isEmpty(options)) {
options = null;
}
reqOptions = helpers.parseReqOptions(options);
body = {
DeleteRequest: {
$: {
xmlns: 'http://exacttarget.com/wsdl/partnerAPI'
}
, Options: options
, Objects: props
}
};
body.DeleteRequest.Objects.$ = { 'xsi:type': type };
updateQueryAllAccounts(body.DeleteRequest, 'Options');
this.soapRequest({
action: 'Delete'
, req: body
, key: 'DeleteResponse'
, retry: true
, reqOptions: reqOptions
}, callback);
};
/**
* This method handles the Describe SOAP Action
* @memberof FuelSoap
* @param {String} type - Will be used in body as `ObjectType` under `DefinitionRequestMsg.DescribeRequests.ObjectDefinitionRequest`
* @param {FuelSoap~StandardCallback} callback - Callback that handles response
*/
FuelSoap.prototype.describe = function(type, callback) {
var body = {
DefinitionRequestMsg: {
$: {
xmlns: 'http://exacttarget.com/wsdl/partnerAPI'
}
, DescribeRequests: {
ObjectDefinitionRequest: {
ObjectType: type
}
}
}
};
this.soapRequest({
action: 'Describe'
, req: body
, key: 'DefinitionResponseMsg'
, retry: true
}, callback);
};
/**
* This method handles the Execute SOAP Actionf
* @memberof FuelSoap
* @param {String} type - xsi:type
* @param {Object} props - Value set in body as `ExecuteRequestMsg.Requests.Parameters`
* @param {FuelSoap~StandardCallback} callback - Callback that handles response
*/
FuelSoap.prototype.execute = function(type, props, callback) {
var body = {
ExecuteRequestMsg: {
$: {
xmlns: 'http://exacttarget.com/wsdl/partnerAPI'
}
, Requests: {
Name: type
, Parameters: props
}
}
};
this.soapRequest({
action: 'Execute'
, req: body
, key: 'ExecuteResponseMsg'
, retry: true
}, callback);
};
/**
* This method handles the Perform SOAP Action
* @memberof FuelSoap
* @param {String} type - xsi:type
* @param {Object} def - definition set in body as `PerformRequestMsg.Definitions.Definition`...only handles one def
* @param {FuelSoap~StandardCallback} callback - Callback that handles response
*/
FuelSoap.prototype.perform = function(type, def, callback) {
def.$ = { 'xsi:type': type }; //This limits us to one def at a time
var body = {
PerformRequestMsg: {
$: {
xmlns: 'http://exacttarget.com/wsdl/partnerAPI'
}
,
"Action":"start",
"Definitions": [
{
"Definition":def
}
]
}
};
this.soapRequest({
action: 'Perform'
, req: body
, key: 'PerformResponseMsg'
, retry: true
}, callback);
};
/**
* This method builds the body of the request
* @private
* @memberof PrivateMethods
* @param {Object} request - Body that will be transformed to XML for API request
* @param {String} token - Access token supplied by `AuthClient`
* @returns {Object} Builder object from xml2js module
*/
FuelSoap.prototype._buildEnvelope = function(request, token) {
var builder;
var envelope = {};
envelope.Body = request;
envelope.$ = {
xmlns: 'http://schemas.xmlsoap.org/soap/envelope/',
'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance'
};
envelope.Header = {
fueloauth: {
$: {
xmlns: 'http://exacttarget.com'
}
, '_': token
}
};
builder = new xml2js.Builder({
rootName: 'Envelope'
, headless: true
});
return builder.buildObject(envelope);
};
/**
* This method parses a filter that will be passed into the body.
* Will recursively create simple filters out of complex filters
* @private
* @memberof PrivateMethods
* @param {Object|String} filter
* @returns {Object}
*/
// TO-DO Handle other simple filter value types like DateValue
FuelSoap.prototype._parseFilter = function(filter) {
var filterType = 'Simple';
var obj = {};
if(isObject(filter.leftOperand) && isObject(filter.rightOperand)) {
filterType = 'Complex';
}
switch(filterType.toLowerCase()) {
case 'simple':
obj.Property = filter.leftOperand;
obj.SimpleOperator = filter.operator;
obj.Value = filter.rightOperand;
break;
case 'complex':
obj.LeftOperand = this._parseFilter(filter.leftOperand);
obj.LogicalOperator = filter.operator;
obj.RightOperand = this._parseFilter(filter.rightOperand);
break;
}
obj.$ = { 'xsi:type': filterType + "FilterPart" };
return obj;
};
/**
* This method parses a filter that will be passed into the body.
* Will recursively create simple filters out of complex filters
* @private
* @memberof PrivateMethods
* @param {String} key - Value used to determine where the response data is
* @returns {Object} body - Data returned from API
* @param {Function} callback - function responsible for delivering reponse
*/
FuelSoap.prototype._parseResponse = function(key, body, callback) {
var parseOptions = {
trim: true
, normalize: true
, explicitArray: false
, ignoreAttrs: true
};
parseString(body, parseOptions, function(err, res) {
if(err) {
err.errorPropagatedFrom = 'xml2js.parseString';
callback(err, null);
return;
}
var soapError;
var soapBody = res['soap:Envelope']['soap:Body'];
// Check for SOAP Fault
if(soapBody['soap:Fault']) {
var fault = soapBody['soap:Fault'];
soapError = new Error(fault.faultstring);
soapError.faultstring = fault.faultstring;
soapError.faultCode = fault.faultcode;
if(fault.detail) {
soapError.detail = fault.detail;
}
soapError.errorPropagatedFrom = 'SOAP Fault';
callback(soapError, null);
return;
}
var parsedRes = soapBody[key];
if(key === 'DefinitionResponseMsg') {
// Return empty object if no ObjectDefinition is returned.
parsedRes.ObjectDefinition = parsedRes.ObjectDefinition || {};
callback(null, parsedRes);
return;
}
// Results should always be an array
parsedRes.Results = Array.isArray(parsedRes.Results) ? parsedRes.Results : isObject(parsedRes.Results) ? [parsedRes.Results] : [];
if(key === 'RetrieveResponseMsg') {
if(parsedRes.OverallStatus === 'OK' || parsedRes.OverallStatus === 'MoreDataAvailable') {
callback(null, parsedRes);
} else {
// This is an error
soapError = new Error(parsedRes.OverallStatus.split(':')[1].trim());
soapError.requestId = parsedRes.RequestID;
soapError.errorPropagatedFrom = 'Retrieve Response';
callback(soapError, null);
}
return;
}
if(parsedRes.OverallStatus === 'Error' || parsedRes.OverallStatus === 'Has Errors') {
soapError = new Error('Soap Error');
soapError.requestId = parsedRes.RequestID;
soapError.results = parsedRes.Results;
soapError.errorPropagatedFrom = key;
callback(soapError, null);
return;
}
callback(null, parsedRes);
}.bind(this));
};
// Methods that need implementations
FuelSoap.prototype.configure = function() {};
FuelSoap.prototype.extract = function() {};
FuelSoap.prototype.getSystemStatus = function() {};
FuelSoap.prototype.query = function() {};
FuelSoap.prototype.schedule = function() {};
FuelSoap.prototype.versionInfo = function() {};
function determineCallbackAndOptions(args, callback, options) {
if(args.length < 4) {
//if options are not included
if(typeof args[2] === 'function') {
callback = options;
options = null;
}
}
return {
callback: callback
, options: options
};
}
function configureQueryAllAccounts(options) {
var addQueryAllAccounts = false;
if(options && options.queryAllAccounts) {
addQueryAllAccounts = true;
delete options.queryAllAccounts;
}
return function(rootElement, child) {
if(addQueryAllAccounts) {
rootElement[child] = rootElement[child] || {};
rootElement[child].QueryAllAccounts = true;
}
};
}
function isObject(value) {
var type = typeof value;
return !!value && (type === 'object' || type === 'function');
}
module.exports = FuelSoap;
/**
* This callback is displayed as part of the Requester class.
* @callback FuelSoap~StandardCallback
* @param {Object} error - error object as node standard
* @param {Object} response - reponse object created from API request
* @param {Object} response.body - Parsed XML response from API
* @param {Object} response.res - Full response from API returned by request module
*/