1 /**	Generate a functional JavaScript interface on top of a REST-like API from a JSON API description.
  2  *	@constructor Creates a new NativeAPI instance.
  3  *	@param {Object} api		The API description
  4  *	@returns {Object}	An object with methods corresponding to the API object's method descriptions.
  5  */
  6 puredom.NativeAPI = function(api) {
  7 	/**	@exports self as puredom.NativeAPI */
  8 	
  9 	var self = (this instanceof puredom.NativeAPI ? this : api) || {},		/* can be used as a method OR a class */
 10 		priv = {},
 11 		globalParameters = {},
 12 		authParameters = {},
 13 		getQueryStringFromObj,
 14 		shallowObjectCopy,
 15 		isArray,
 16 		objConstructor = ({}).constructor,
 17 		createApiMethod,
 18 		NativeAPIMethod,
 19 		MessageStringWithData,
 20 		createNativeAPIResponse,
 21 		emptyFunc = function(){},
 22 		log;
 23 	
 24 	/**	@private */
 25 	log = function(text) {
 26 		if (self.enableLogging!==false && window.console && window.console.log) {
 27 			window.console.log(text);
 28 		}
 29 	};
 30 	
 31 	/**	@private */
 32 	getQueryStringFromObj = function (obj) {
 33 		var querystring = "",
 34 			x, i;
 35 		for (x in obj) {
 36 			if (obj[x]!==null && obj[x]!==undefined && obj[x].constructor!==Function && obj[x].constructor!==objConstructor && puredom.typeOf(obj[x])!=='object' && !isArray(obj[x])) {
 37 				querystring += "&" + encodeURIComponent(x) + "=" + encodeURIComponent(obj[x]);
 38 			}
 39 			else if (obj[x] && isArray(obj[x])) {
 40 				for (i=0; i<obj[x]; i++) {
 41 					querystring += "&" + encodeURIComponent(x) + "[]=" + encodeURIComponent(obj[x]);
 42 				}
 43 			}
 44 		}
 45 		return querystring;
 46 	};
 47 	
 48 	/**	@private */
 49 	shallowObjectCopy = function(base, args) {
 50 		var i, p, obj;
 51 		for (i=1; i<arguments.length; i++) {
 52 			obj = arguments[i];
 53 			for (p in obj) {
 54 				if (obj.hasOwnProperty(p)) {
 55 					base[p] = obj[p];
 56 				}
 57 			}
 58 		}
 59 		return base;
 60 	};
 61 	
 62 	/**	@private */
 63 	isArray = function(what) {
 64 		return Object.prototype.toString.apply(what)==="[object Array]";
 65 	};
 66 	
 67 	/**	@private */
 68 	NativeAPIMethod = function NativeAPIMethod(){};
 69 	
 70 	/**	@class Wrapped String/Data pair
 71 	 *	@name puredom.NativeAPI.MessageStringWithData
 72 	 */
 73 	self.MessageStringWithData = MessageStringWithData = function MessageStringWithData(data, message){
 74 		this.message = message || '';
 75 		shallowObjectCopy(this, data);
 76 	};
 77 	/**	@private */
 78 	MessageStringWithData.prototype.toString = MessageStringWithData.prototype.toSource = function(){
 79 		return this.message;
 80 	};
 81 	
 82 	/**	@private */
 83 	createNativeAPIResponse = function(data, originalResponse) {
 84 		var response;
 85 		if (puredom.isArray(data.data) && data.data.length===0) {
 86 			data.data = {};
 87 		}
 88 		
 89 		/**	@class Represents a response from NativeAPI's methods.
 90 		 *	@name puredom.NativeAPI.NativeAPIResponse
 91 		 *	@ignore
 92 		 */
 93 		function NativeAPIResponse(){}
 94 		shallowObjectCopy(NativeAPIResponse.prototype, {
 95 			getData : function() {
 96 				return puredom.extend({}, this);
 97 			},
 98 			getResponse : function() {
 99 				return this.constructor.prototype._originalResponse;
100 			},
101 			_originalResponse : originalResponse
102 		});
103 		
104 		response = new NativeAPIResponse();
105 		shallowObjectCopy(response, data);
106 		return (function() {
107 			data = response = originalResponse = null;
108 			return arguments[0];
109 		}(response));
110 	};
111 	
112 	/**	Set a paramter that will be passed on all requests. */
113 	self.setGlobalParameter = function (key, value) {
114 		if (value===undefined || arguments.length<2) {
115 			delete globalParameters[key];
116 		}
117 		else {
118 			globalParameters[key] = value;
119 		}
120 	};
121 	
122 	/**	Set a paramter that will be passed on all requests that require authentiation (eg: a token). */
123 	self.setAuthParameter = function (key, value) {
124 		if (value===undefined || arguments.length<2) {
125 			delete authParameters[key];
126 		}
127 		else {
128 			authParameters[key] = value;
129 		}
130 	};
131 	
132 	
133 	/**	@ignore */
134 	priv.cache = {};
135 	
136 	//window._inspectNativeApiCache = function(){ return priv.cache; };
137 	
138 	
139 	/** @private */
140 	priv.getCacheKey = function(method, options) {
141 		var key = '', list=[], i, props=[];
142 		options = puredom.extend({}, options || {});
143 		delete options.callback;
144 		key = (method.type || '') + '||' + method.endpoint + '||';
145 		for (i in options) {
146 			if (options.hasOwnProperty(i) && i!=='_cache' && i!=='_nocache' && i!=='_cache_deleteonly' && i!=='callback') {
147 				props.push(i);
148 			}
149 		}
150 		props.sort();
151 		for (i=0; i<props.length; i++) {
152 			list.push(encodeURIComponent('o_'+props[i]) + '=' + encodeURIComponent(options[props[i]]+''));
153 		}
154 		key += list.join('&');
155 		return key;
156 	};
157 	
158 	/** @private Get the cached request if it exists */
159 	priv.getCached = function(method, options) {
160 		var key = priv.getCacheKey(method, options),
161 			entry = priv.cache.hasOwnProperty(key) ? puredom.json.parse(priv.cache[key]) : null;
162 		if (self.enableCacheLogging===true) {
163 			console.log('CACHE: Getting ['+method.type+'] '+method.endpoint, options, ' --> ', entry);
164 		}
165 		return entry;
166 	};
167 	
168 	/** @private Delete a cache entry */
169 	priv.uncache = function(method, options, response) {
170 		var key = priv.getCacheKey(method, options);
171 		if (self.enableCacheLogging===true) {
172 			console.log('CACHE: Clearing ['+method.type+'] '+method.endpoint, options);
173 		}
174 		delete priv.cache[key];
175 	};
176 	
177 	/** @private Cache a response, overwriting existing */
178 	priv.cacheResponse = function(method, options, response) {
179 		var key = priv.getCacheKey(method, options);
180 		if (self.enableCacheLogging===true) {
181 			console.log('CACHE: Storing ['+method.type+'] '+method.endpoint, options, ' --> ', response);
182 		}
183 		priv.cache[key] = puredom.json.stringify(response);
184 	};
185 	
186 	/** @public Purge the internal request cache */
187 	self.clearCache = function() {
188 		if (self.enableCacheLogging===true) {
189 			console.log('CACHE: Purging all entries');
190 		}
191 		priv.cache = {};
192 	};
193 	
194 	
195 	/** @private Validate submitted values against an API method definition */
196 	priv.validateParameters = function(options, method) {
197 		var requiredParams, name, p, pType, errorType, inputField, error,
198 			response = {
199 				errors : [],
200 				message : ''
201 			},
202 			baseErrorMessage = {
203 				nativeApiError : true,
204 				clientSideError : true
205 			};
206 		
207 		if (method.parameters) {
208 			requiredParams = {};
209 			for (name in method.parameters) {
210 				if (method.parameters.hasOwnProperty(name)) {
211 					p = method.parameters[name];
212 					pType = puredom.typeOf(p);
213 					// convert direct references to constructors to their lowercase'd names:
214 					if (pType==='function' && p.name) {
215 						pType = 'string';
216 						p = p.name.toLowerCase();
217 					}
218 					else if (pType==='regexp' || p.constructor===RegExp) {
219 						pType = 'string';
220 						p = '/' + p.source + '/' + (p.global?'g':'') + (p.ignoreCase?'i':'') + (p.multiline?'m':'');
221 					}
222 					// all validation types are strings
223 					if (pType==='string') {
224 						if (p.substring(0,1)==='/') {
225 							p = (/^\/(.*?)\/([gim]*?)$/gim).exec(p);
226 							requiredParams[name] = {
227 								validate : 'regex',
228 								against : new RegExp(p[1] || '', p[2] || '')
229 							};
230 						}
231 						else {
232 							requiredParams[name] = {
233 								validate : 'type',
234 								against : p.toLowerCase()
235 							};
236 						}
237 					}
238 				}
239 			}
240 		}
241 		
242 		if (requiredParams) {
243 			for (name in requiredParams) {
244 				if (requiredParams.hasOwnProperty(name)) {
245 					pType = requiredParams[name].validate;
246 					p = requiredParams[name].against;
247 					inputField = options[name];
248 					error = null;
249 					if (pType==='regex') {
250 						p.lastIndex = 0;
251 					}
252 					if (!options.hasOwnProperty(name) || inputField===null || inputField===undefined || inputField==='') {
253 						error = {
254 							field : name,
255 							type : 'RequiredError',
256 							message : '{fieldnames.' + name + '} is required'
257 						};
258 					}
259 					else if (pType==='regex' && !p.test(inputField+'')) {
260 						error = {
261 							field : name,
262 							type : 'ValidationError',
263 							message : '{fieldnames.' + name + '} is invalid'
264 						};
265 					}
266 					else if (pType==='type' && p!==puredom.typeOf(inputField)) {
267 						error = {
268 							field : name,
269 							type : 'TypeError',
270 							message : '{fieldnames.' + name + '} is invalid'
271 						};
272 					}
273 					if (error) {
274 						error.missingParameter = error.field;
275 						response.message += (response.message.length>0?', ':'') + error.message + ' ('+error.type+')';
276 						response.errors.push(puredom.extend(error, baseErrorMessage));
277 					}
278 				}
279 			}
280 		}
281 		
282 		response.failed = response.errors.length>0;
283 		return response;
284 	};
285 	
286 	
287 	
288 	createApiMethod = function(subject, action, method) {
289 		self[subject][action] = function (options) {
290 			var validationResponse = priv.validateParameters(options=options||{}, method),
291 				callback, req, type, querystring, x, i,
292 				funcs, postData, requestParameters,
293 				isLongPolling = method.longPolling===true || (method.allowLongPolling===true && options.longPolling===true),
294 				optionsHasLongPollingProperty = options.hasOwnProperty('longPolling'),
295 				optionsLongPollingProperty = options.longPolling,
296 				unCache;
297 			
298 			// Check for a failed validation
299 			if (validationResponse.failed) {
300 				log("api."+subject+"."+action+": Errors: " + validationResponse.message, 9);
301 				
302 				// for historical reasons, return the first error on its own, then return the error collection as a third param:
303 				options.callback(false, validationResponse.errors[0], validationResponse.errors);
304 				return false;
305 			}
306 			
307 			// callback for JSONp (or other request methods)
308 			callback = function(json, extraData) {
309 				var success = json && (json[api.statusProperty || 'success']===true || json[api.statusProperty || 'success']===1),			// The response.success property MUST be Boolean TRUE or Integer 1  <---
310 					data = json.data || json || null,
311 					message = json.errorMessage || json.message || null,
312 					sval = json[api.statusProperty || 'success'],
313 					optionsLongPollingRef,
314 					enhancedMessage;
315 				
316 				// Some enhanced functionality used by error messages.
317 				extraData = extraData || {};
318 				extraData.apiMethod = method.endpoint;
319 				
320 				if (extraData.cached!==true && method.cache===true && self.enableCache===true) {
321 					priv.cacheResponse(method, options, json);
322 				}
323 				
324 				if (method.verifyResult) {
325 					success = method.verifyResult(json);
326 				}
327 				else if ((sval!==true && sval!==false && sval!==0 && sval!==1) || (json.constructor!==objConstructor && !isArray(json))) {
328 					success = (json.constructor===objConstructor || isArray(json)) ? true : false;
329 					data = json;
330 					message = null;
331 				}
332 				
333 				// Long Polling!
334 				if (isLongPolling) {
335 					// Did the server respond with "timedout":true?
336 					if (success && json.timedout===true) {
337 						// Looks like we need to re-initiate the request:
338 						optionsLongPollingRef = options;
339 						
340 						setTimeout(function() {
341 							self[subject][action](optionsLongPollingRef);
342 							callback = optionsLongPollingRef = success = json = data = message = sval = null;
343 						}, 1);
344 						// We'll respond later.
345 						return true;
346 					}
347 				}
348 				
349 				// Complex response objects allow for passing more data using the existing structure:
350 				data = createNativeAPIResponse(data, json, extraData);
351 				enhancedMessage = new MessageStringWithData(json, message);
352 				
353 				if (extraData.parseError===true) {
354 					success = false;
355 					data = data.message || data;
356 					if (api.onParseError) {
357 						api.onParseError(json, extraData);
358 					}
359 				}
360 				
361 				(method.onbeforecomplete || method.onBeforeComplete || method.precallback || emptyFunc)(success, success===true?data:enhancedMessage, json);
362 				if (success===true) {
363 					(options.onsuccess || method.onsuccess || emptyFunc).call(api.endpoints[subject][action], data, json);
364 				}
365 				else {
366 					(options.onerror || method.onerror || emptyFunc).call(api.endpoints[subject][action], message, data, json);
367 				}
368 				(options.oncomplete || options.callback || emptyFunc).call(api.endpoints[subject][action], success,success===true?data:enhancedMessage, json);
369 				(method.oncomplete || method.onComplete || method.callback || emptyFunc)(success, success===true?data:enhancedMessage, json);
370 				if (api.onRequestCompleted) {
371 					api.onRequestCompleted(subject+'.'+action, data, success, requestParameters, options);
372 				}
373 				callback = req = type = querystring = funcs = postData = requestParameters = isLongPolling = optionsHasLongPollingProperty = optionsLongPollingProperty = unCache = enhancedMessage = null;
374 				options = p = null;
375 			};//-callback
376 			
377 			// general request prep
378 			type = (method.type && method.type.toLowerCase()) || "";
379 			querystring = method.endpoint;
380 			if (method.formatSuffix) {
381 				querystring += method.formatSuffix;
382 			}
383 			else if (api.formatSuffix) {
384 				querystring += api.formatSuffix;
385 			}
386 			if (!querystring.match(/^(http|https|ftp)\:/)) {
387 				querystring = api.root + querystring;
388 			}
389 			
390 			unCache = options._cache===false || options._nocache===true;
391 			//delete options._cache;
392 			//delete options._nocache;
393 			
394 			if (isLongPolling) {
395 				options.longPolling = null;
396 				try{ delete options.longPolling; }catch(err){}
397 				options.timeout = options.timeout || method.longPollingTimeout || self.longPollingTimeout || 60;
398 			}
399 			else if (method.cache===true && self.enableCache===true) {
400 				//console.log(method.endpoint, puredom.extend({}, options));
401 				if (unCache) {
402 					priv.uncache(method, options);
403 					if (options._cache_deleteonly===true) {
404 						return;
405 					}
406 				}
407 				else {
408 					i = priv.getCached(method, options);
409 					if (i) {
410 						setTimeout(function() {
411 							callback(i, {
412 								cached : true,
413 								fresh : false
414 							});
415 						}, 1);
416 						return;
417 					}
418 				}
419 			}
420 			
421 			//console.log('NativeAPI::querystring = ' + querystring);
422 			if (options) {
423 				querystring = querystring.replace(/\{([a-z0-9\-\._]+)\}/gim, function(s, i) {
424 					//console.log('NativeAPI::tpl('+i+', '+((options.hasOwnProperty(i) && options[i]!==null && options[i]!==undefined)?'true':'false')+')');
425 					if (options.hasOwnProperty(i) && options[i]!==null && options[i]!==undefined) {
426 						try {
427 							delete options[i];
428 						} catch(err) {
429 							options[i] = null;
430 						}
431 						return options[i];
432 					}
433 					return s;
434 				});
435 			}
436 			
437 			
438 			// specific request prep
439 			switch (type) {
440 				case "xdr":
441 					log("Cross-domain requests are not yet supported.", 7);
442 					break;
443 				
444 				case "post":
445 					funcs = {};
446 					for (var j in options) {
447 						if (options.hasOwnProperty(j) && Object.prototype.toString.apply(options[j])==="[object Function]") {
448 							funcs[j] = options[j];
449 							options[j] = null;
450 							try{ delete options[j]; }catch(err2){}
451 						}
452 					}
453 					requestParameters = postData = shallowObjectCopy(
454 						{},
455 						globalParameters,
456 						method.auth===true ? authParameters : {},
457 						method.defaultParameters || {},
458 						options
459 					);
460 					for (j in funcs) {
461 						if (funcs.hasOwnProperty(j)) {
462 							options[j] = funcs[j];
463 						}
464 					}
465 					funcs = null;
466 					
467 					// make the POST request
468 					puredom.net.request({
469 						url : querystring,
470 						type : "POST",
471 						post : postData,
472 						callback : function(success, response) {
473 							if (success && response) {
474 								callback(response);
475 							}
476 							else {
477 								if (this.jsonParseError===true) {
478 									callback({status:false, message:"Unable to parse server response", rawdata:this.responseText}, {parseError:true, clientsideErrorDetection:true});
479 								}
480 								else {
481 									callback({status:false, message:"Connection error "+this.status}, {clientsideErrorDetection:true});
482 								}
483 							}
484 						},
485 						contentTypeOverride : 'application/json'
486 					});
487 					break;
488 				
489 				case "jsonp":
490 					// TODO: re-write the following line, it does not handle arrays properly:
491 					requestParameters = shallowObjectCopy(
492 						{},
493 						globalParameters,
494 						method.auth===true ? authParameters : {},
495 						method.defaultParameters || {},
496 						options
497 					);
498 					querystring += getQueryStringFromObj(requestParameters);
499 					
500 					// replace the first "&" with a "?"
501 					if (querystring.indexOf("?")===-1 || querystring.indexOf("?")>querystring.indexOf("&")) {
502 						querystring = querystring.replace("&","?");
503 					}
504 					
505 					//console.log('puredom.net.jsonp(', querystring, callback, ');');
506 					
507 					// make the JSONp call
508 					req = puredom.net.jsonp(querystring, callback);
509 					break;
510 				
511 				//case "get":
512 				default:
513 					// TODO: re-write the following line, it does not handle arrays properly:
514 					requestParameters = shallowObjectCopy(
515 						{},
516 						globalParameters,
517 						method.auth===true ? authParameters : {},
518 						method.defaultParameters || {},
519 						options
520 					);
521 					querystring += getQueryStringFromObj(requestParameters);
522 					
523 					// replace the first "&" with a "?"
524 					if (querystring.indexOf("?")===-1 || querystring.indexOf("?")>querystring.indexOf("&")) {
525 						querystring = querystring.replace("&","?");
526 					}
527 					
528 					// make the GET request
529 					puredom.net.request({
530 						url : querystring,
531 						type : "GET",
532 						callback : function(success, response) {
533 							if (success && response) {
534 								callback(response);
535 							}
536 							else {
537 								if (this.jsonParseError===true) {
538 									callback({status:false, message:"Unable to parse server response", rawdata:this.responseText}, {parseError:true, clientsideErrorDetection:true});
539 								}
540 								else {
541 									callback({status:false, message:"Connection error "+this.status}, {clientsideErrorDetection:true});
542 								}
543 							}
544 						},
545 						contentTypeOverride : 'application/json'
546 					});
547 					break;
548 			}
549 			
550 			if (optionsHasLongPollingProperty) {
551 				options.longPolling = optionsLongPollingProperty;
552 			}
553 			
554 			return req;
555 		};
556 	};
557 	
558 	
559 	if (self.constructor===({}).constructor) {
560 		self = (function(obj) {
561 			/**	@ignore */
562 			function NativeAPI(){}
563 			for (var i in obj) {
564 				if (obj.hasOwnProperty(i)) {
565 					NativeAPI.prototype[i] = obj[i];
566 				}
567 			}
568 			return new NativeAPI();
569 		}(self));
570 	}
571 	
572 	
573 	var subject, action;
574 	for (subject in api.endpoints) {
575 		if (api.endpoints.hasOwnProperty(subject)) {
576 			self[subject] = new NativeAPIMethod();
577 			for (action in api.endpoints[subject]) {
578 				if (api.endpoints[subject].hasOwnProperty(action)) {
579 					createApiMethod(subject, action, api.endpoints[subject][action]);
580 				}
581 			}
582 		}
583 	}
584 	
585 	if (self.globalParameters) {
586 		for (var o in self.globalParameters) {
587 			if (self.globalParameters.hasOwnProperty(o)) {
588 				self.setGlobalParameter(o, self.globalParameters[o]);
589 			}
590 		}
591 	}
592 	
593 	if (this.constructor!==arguments.callee) {
594 		return self;
595 	}
596 };