1 /**
  2  * @author Brian Carlsen
  3  * @version 1.0.0
  4  *
  5  * Serves as a base class for interacting with the actual 
  6  * MINDBODY services: Client, Class, Sale, Staff, and Site.
  7  */
  8 
  9 var	EventEmitter 		= require( 'events' ).EventEmitter,
 10 	soap 				= require( 'soap' ),
 11 	Promise 			= require( 'bluebird' ),
 12 	OperationalError 	= Promise.OperationalError;
 13 
 14 var Credentials = require( '../classes/mbo_Credentials' ),
 15 	SOAPError 	= require( '../classes/SOAPError' ),
 16 	mboLogger 	= require( '../../logger/mbo_Logger' );
 17 
 18 //******************************//
 19 
 20 /**
 21  * This class is not meant to be instantiated, and only serves as a base class for the actual Service classes.
 22  * 
 23  * Creates a new MBO Service.
 24  * Retrieves the WSDL of the given service and creates two methods for each SOAP method in the WSDL.
 25  * For each SOAP method a function is created:
 26  * 1) Bearing the same name that either extracts the result of the same name, and
 27  * 2) Bearing the name with 'Response' post-fixed, which returns an array of the
 28  * 		i) 		Raw response converted to a JS object
 29  * 		ii) 	Raw XML response
 30  * 		iii) 	Raw XML SOAP header info
 31  * Each of these functions returns an A+ Promise.
 32  *
 33  * Each method of the service methods accepts an Object as a parameter as well.
 34  * Each element of the object will be included in the Request section of the SOAP Request.
 35  *
 36  * Emits an 'initialized' event once all methods have been defined.
 37  * A 'ready' event is triggered by the ServiceFactory once User Credentails have been set.
 38  *
 39  * @constructor
 40  * @param  {string|boolean} service 	The full name of the service to be implemented. E.g 'SaleService'
 41  		Set to False if no service is desired.
 42  * @param  {string} sourceName     	Your MINDBODY developer Source Name, included in all service calls.
 43  * @param  {string} sourcePassword 	Your MINDBODY developer Source Password, included in all service calls. 
 44  * @param  {int[]} [siteIds] 	   	An array containg site Ids to add.
 45  * @param  {number} apiVersion 		The MINDBODY API version you wish to use. 
 46  * 									Defaults to the most recent stable release.
 47  *
 48  * @return {mbo_Service} An absract service to interact with the MINDBODY API service.
 49  */	
 50 function mbo_Service( service, sourceName, sourcePassword, siteIds, apiVersion = 5.0 ) {
 51 	var self = this;
 52 	self.name = service;
 53 	self.mboLogger = undefined;
 54 	self.emitter = new EventEmitter();
 55 	
 56 	self.ready = false;
 57 	self.on( 'ready', function() { self.ready = true; } );
 58 
 59 	self.sourceCredentials = new Credentials( sourceName, sourcePassword, siteIds, 'source' );
 60 	self.userCredentials = undefined;
 61 	self._useDefaultUserCredentials = false;
 62 
 63 	// Request Defaults
 64 	self.requestDefaults = {
 65 		XMLDetail: 'Full',
 66 		PageSize: '1000' 
 67 	};
 68 
 69 	self.apiVersion = apiVersion;
 70 	self.apiVersionString = ( function( version ) {
 71 		switch( verison ) {
 72 			case 5.0: return '0_5'; 	break;
 73 			case 5.1: return '0_5_1'; 	break;
 74 		};
 75 	} )( apiVersion )
 76 
 77 	if( service === false ) {
 78 		self.emit( 'initialized' );
 79 		return self;
 80 	}
 81 
 82 	// Setup SOAP Client 
 83 	soap.createClientAsync( 'https://api.mindbodyonline.com/' + self.apiVersionString + '/' + service + '.asmx?wsdl' )
 84 		.then( function( client ) {
 85 			Promise.promisifyAll( client );
 86 			self.service = client; 
 87 
 88 			// Add Service Functions to the Class
 89 			var description = client.describe(); 
 90 			for ( var service in description ) { 
 91 				for ( var port in description[ service ] ) {
 92 					for ( var fcn in description[ service ][ port ] ) { 
 93 						if ( self[ fcn ] === undefined ) { // Only create function if name is not taken.
 94 							// Defines the standard funtion, with extracted results
 95 							self[ fcn ] = self._defineMethod( fcn, description[ service ][ port ][ fcn ], true );
 96 						}
 97 
 98 						if ( self[ fcn + 'Response' ] === undefined ) { // Only create function if name is not taken.
 99 							// Defines the full function, returning the entire reponse, without extracting results
100 							self[ fcn + 'Response' ] = self._defineMethod( fcn, description[ service ][ port ][ fcn ], false );
101 						}
102 					}
103 				}
104 			}
105 	
106 			self.emit( 'initialized' );
107 		} )
108 		.catch( function( err ) {
109 			throw err;
110 		} ); 
111 }
112 
113 // Array of meta info keys common to all responses
114 mbo_Service.metaInfoKeys = [ 
115 	'Status',
116 	'ErrorCode',
117 	'Message',
118 	'XMLDetail',
119 	'ResultCount',
120 	'CurrentPageIndex',
121 	'TotalPageCount',
122 	'targetNSAlias',
123 	'targetNamespace'
124 ];
125 
126 /**
127  * Enables or disables the logging of calls to the API.
128  *
129  * @param {string|boolean} type The type of Logger to use -- 'local' or 'remote' --
130  *		or false to disable.
131  * @param {string} 
132  * 
133  * @throws {Error}
134  */
135 mbo_Service.prototype.log = function( type, path, host, port ) {
136 	try {
137 		this.mboLogger = ( type === false ) ? undefined : new mbo_Logger( type );
138 		this.mboLogger.setPath( path );
139 
140 		// remote only
141 		if( type === 'remote' ) {
142 			this.mboLogger.setHost( host, port );
143 		}
144 	}
145 	catch( err ) {
146 		throw err;
147 	}
148 }
149 
150 /**
151  * Sets the User Credentials to use for any call, and sets them to be used.
152  * Not all calls require user credentials.
153  * 
154  * @param {string} username The username of the MINDOBDY client you're interacting with.
155  * @param {string} password The password of the MINDBODY client you're interacting with.
156  * @param {number|number[]} siteIds  A single, or array of, Site ID(s) which the client can interact with.
157  */
158 mbo_Service.prototype.setUserCredentials = function( username, password, siteIds ) {
159 	if ( this._useDefaultUserCredentials ) {
160 		this._useDefaultUserCredentials = false;
161 	}
162 
163 	this.userCredentials = new Credentials( username, password );
164 
165 	if ( siteIds ) {
166 		this.addSiteIds( siteIds );
167 	}
168 };
169 
170 /**
171  * Sets the default credentials to the default value.
172  * This appends an underscore before the Source Name, and uses the Source's password.
173  */
174 mbo_Service.prototype.useDefaultUserCredentials = function( val ) {
175 	if ( typeof val === 'undefined' ) {
176 		val = true;
177 	}
178 
179 	this._useDefaultUserCredentials = !!val;
180 };
181 
182 /**
183  * Adds Site Ids to the current users accessible sites.
184  * @param {number|number[]} siteIds A single, or array of, Site ID(s) which the client can interact with.
185  */
186 mbo_Service.prototype.addSiteIds = function( siteIds ) {
187 	if ( this.userCredentials ) {
188 		this.userCredentials.addSiteIds( siteIds );
189 	}
190 	
191 	this.sourceCredentials.addSiteIds( siteIds ); // Syncs User and Source Site Ids
192 };
193 
194 /**
195  * Gets or Sets defaults passed to every request.
196  * If second argument is included, the key is set to that value.
197  * If the second parameter is not included, the current value of the key is returned.
198  * 
199  * @param {string} name The key of the default parameter to get or set
200  * @param {string} [value] The value to set the key to.
201  * @return {string} If getting a value the value is returned. If setting a value, nothing is returned.
202  */
203 mbo_Service.prototype.defaultParam = function( key, value ) {
204 	if ( typeof value === 'undefined' ) { // Getter
205 		return this.requestDefaults[ key ];
206 	}
207 	else { // Setter
208 		this.requestDefaults[ key ] = value.toString();
209 	}
210 };
211 
212 mbo_Service.prototype._setUserCredentialsToDefault = function() {
213 	var username 	= '_' + this.sourceCredentials.username,
214 		password 	= this.sourceCredentials.password,
215 		siteIds 	= this.sourceCredentials.siteIds;
216 
217 	if ( siteIds.length === 1 && siteIds[ 0 ] === -99 ) {
218 		// For Test site only, Default username doesn't have '_' prefixed
219 		username = 'Siteowner';
220 		password = 'apitest1234';
221 	}
222 
223 	this.userCredentials = new Credentials( username, password );
224 	this.userCredentials.addSiteIds( siteIds )
225 };
226 
227 /**
228  * Defines a method to be added to the Service.
229  * @param  {string} name           The name of the SOAP method to be wrapped.
230  * @param  {Object} signature      The SOAP method's signature including the input parameters, and output object.
231  * @param  {boolean} extractResults Whether the method should attempt to automatically extract the desired result or not.
232  * @return {function}                Returns the wrapped SOAP method.
233  *
234  * @throws {SOAPError} If response code is not 200 Success.
235  */
236 mbo_Service.prototype._defineMethod = function( name, signature, extractResults ) {
237 	var self = this;
238 	return function( args ) {
239 		var params = {
240 			Request: {
241 				SourceCredentials: self.sourceCredentials.toSOAP()
242 			}
243 		};
244 
245 		if ( self._useDefaultUserCredentials ) {
246 			self._setUserCredentialsToDefault();
247 		}
248 		
249 		if ( self.userCredentials ) {
250 			params.Request.UserCredentials = self.userCredentials.toSOAP() 
251 		}
252  
253 		for ( var dflt in self.requestDefaults ) { // Default arguments
254 			params.Request[ dflt ] = self.requestDefaults[ dflt ];
255 		}
256 
257 		for ( var arg in args ) { // Passed in arguments
258 			params.Request[ arg ] = args[ arg ];
259 		}
260 
261 		// Run the function
262 		return ( self.service[ name + 'Async' ] )( params )
263 			.spread( function( result, raw, header ) {
264 
265 				// Logging
266 				if ( self.mboLogger ) {
267 					self.mboLogger.log( self.name, params, name, result );
268 				}
269 
270 				// Check for Errors
271 				var res = result[ name + 'Result' ];
272 				if ( res.ErrorCode !== 200 ) { // SOAP Fault occured
273 					
274 					var fault = {
275 						Status: 	res.Status,
276 						ErrorCode: 	res.ErrorCode,
277 						Message: 	res.Message
278 					};
279 
280 					throw new SOAPError( '[ErrorCode ' + fault.ErrorCode + '] ' + fault.Message );
281 				}
282 				else { // Successful Request, No Errors, so extract results
283 					return Promise.resolve( [ result, raw, header ] );
284 				}
285 			} )
286 			.spread( function( result, raw, header ) {
287 				if ( extractResults ) { // Extract Relevant info
288 					if ( name.substr( 0, 3 ) === 'Get' ) { // Function is a Getter, Extract relevant results
289 						return self._extractGetterResults( result[ name + 'Result' ] );
290 					}
291 					else { // Function performs an action with Side effects, Extract non-meta info
292 						return self._extractActionResults( result[ name + 'Result' ] );
293 					}	
294 				}
295 				else { // Return raw result
296 					return [ result, raw, header ];
297 				}
298 			} )
299 			.catch(	function( err ) {
300 				if ( err instanceof Error ) { // Rethrow error
301 					throw err;
302 				}
303 				else {
304 					return self._defaultSoapErrorHandler( err );
305 				} 
306 			} );
307 	};
308 };
309 
310 /**
311  * Check if the SOAP call returned a SOAP Fualt.
312  * Triggers a 'SoapFault' event if found.
313  *
314  * @deprecated MBO Services respond with status codes instead of SOAP Faults.
315  * @param  {object} result The object representation of the SOAP response.
316  * @return {boolean}        Whether the response contained a SOAP Fault of not.
317  */
318 mbo_Service.getSoapFault = function( result ) {
319 	for ( var key in result ) {
320 		if ( 'Status' in result[ key ] ) {
321 			if ( result[ key ].ErrorCode === 200 ) { // No error
322 				return false;
323 			}
324 			else { // Error
325 				this.emit( 'SoapFault', fault );
326 
327 				return { 
328 					ErrorCode: result[ key ].ErrorCode,
329 					Status: result[ key ].Status,
330 					Message: result[ key ].Message
331 				};
332 			}
333 		}
334 	}
335 };
336 
337 /* Default SOAP Error Handler. To be used if SOAP request returns a SOAPFault.
338  * 
339  */
340 mbo_Service.prototype._defaultSoapErrorHandler = function( err ) {
341 	console.error( err );
342 	return Promise.reject( err );
343 };
344 
345 /**
346  * Attempts to exract the results from an API request.
347  * It does this by eliminating all metadata.
348  * 	
349  * @param  {Object} result The Object representation of the SOAP response.
350  * @return {string|number|Array}        Returns either an Array of results or,
351  *                                              if only 1 non-metadata element existed in the
352  *                                              response, returns the actual data.
353  */
354 mbo_Service.prototype._extractGetterResults = function( result ) {
355 	for ( var resultKey in result ) { 
356 		if ( mbo_Service.metaInfoKeys.indexOf( resultKey ) === -1 ) { 
357 		// Key is not meta info, meanining it contains the results we're interested in
358 
359 			var extracted = {};
360 			for ( var key in result[ resultKey ] ) {
361 				if ( mbo_Service.metaInfoKeys.indexOf( key ) === -1 ) {
362 				// Again, key is not meta info, it must be the result we want
363 
364 					extracted[ key ] = result[ resultKey ][ key ];
365 				}
366 			}
367 
368 			var extractedKeys = Object.keys( extracted );
369 			if ( extractedKeys.length === 1 ) { // Only one result to return, Return raw result
370 				return Promise.resolve( extracted[ extractedKeys[ 0 ] ] ); 
371 			}
372 			else { // More than one result, return whole object
373 				return Promise.resolve( extracted ); 
374 			}
375 		}
376 	}
377 
378 	// Couldn't find a result
379 	return Promise.reject( 
380 		new OperationalError ( '[ErrorCode 701] Could not extract results. Try using the service function instead.' )
381 	);
382 };
383 
384 /**
385  * Extracts the results from an API call with a side effect.
386  * @param  {object} result The Object representation of a SOAP response.
387  * @return {Array}        An array containing any non-metadata from the response.
388  */
389 mbo_Service.prototype._extractActionResults = function( result ) {
390 	var extracted = { ResultCount: result.ResultCount };
391 
392 	for ( var resultKey in result ) {
393 		if ( mbo_Service.metaInfoKeys.indexOf( resultKey ) === -1 ) { 
394 		// Key is not meta info, meanining it contains a result we're interested in
395 			extracted[ resultKey ] = result[ resultKey ];
396 		}
397 	}
398 
399 	return Promise.resolve( extracted );
400 };
401 
402 mbo_Service.prototype.toString = function() {
403 	return '[object mbo_Service] {' +
404 			'service: ' + this.service + ', ' +
405 			'sourceCredentials: ' + this.sourceCredentials + ', ' +
406 			'userCredentials: ' + this.userCredentials + ', ' +
407 			'ready: ' + this.ready + ', ' +
408 			'requestDefaults: ' + this.requestDefaults + '}';
409 }
410 
411 //------------ Event Methods -------------------
412 
413 mbo_Service.prototype.on = function( event, listener ) {
414 	this.emitter.on( event, listener );
415 };
416 
417 mbo_Service.prototype.once = function( event, listener ) {
418 	this.emitter.on( event, listener );
419 }
420 
421 mbo_Service.prototype.emit = function( event ) {
422 	this.emitter.emit( event );
423 };
424 
425 module.exports = mbo_Service;