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 };