Daniel@0: "use strict"; Daniel@0: /** Daniel@0: * The module wraps all API calls to the data server and abstracts JSONP calls. Daniel@0: * Daniel@0: * API spec: https://code.soundsoftware.ac.uk/projects/dml/wiki/Vis_API_-_front_end_~_data_server Daniel@0: * Daniel@0: * Daniel@0: * Module options: Daniel@0: * Daniel@0: * apiRootPaths Daniel@0: * A pool of URLs from where the data are obtained. Multiple entry points (domains) can be used. Daniel@0: * The choice of a particular root path is "random", i.e. this is a "hash" function of a query contents: Daniel@0: * "doThis?x=1&y=1" -> path 1 of 3 Daniel@0: * "doThat?x=1&y=1" -> path 3 Daniel@0: * "doThat?x=1&y=123" -> path 1 Daniel@0: * "doThat?x=1&y=2" -> path 2 Daniel@0: * ... Daniel@0: * "doThis?x=1&y=1" -> path 1 Daniel@0: * Daniel@0: * The same query is always requested from the same instance (domain) to facilitate HTTP caching. Daniel@0: * // instead of http:// and https:// can be used for protocol auto-selection. Daniel@0: * Trailing slash is compulsory. Daniel@0: * Daniel@0: * Examples: Daniel@0: * ["//127.0.0.1/"], Daniel@0: * ["//127.0.0.1/my-path/to/dml-api/"], Daniel@0: * ["//a.example.com/dml-api/", "//b.example.com/dml-api/", "//c.example.com/dml-api/"], Daniel@0: * Daniel@0: * apiVersion Daniel@0: * A natural number representing the version of the data API (i.e. the available features of the interface) Daniel@0: * Daniel@0: * Examples: Daniel@0: * 1 Daniel@0: * 42 Daniel@0: * Daniel@0: * dataVersion Daniel@0: * A natural number or a string representing the version of the selected music library. Daniel@0: * As API calls are supplemented with dv=[dataVersion], a change in the value of dataVersion drops the HTTP cache. Daniel@0: * dataVersion is delivered to the front end with the contents of the web page. Daniel@0: * Daniel@0: * Examples: Daniel@0: * 42 Daniel@0: * "2015-01-01" Daniel@0: * Daniel@0: */ Daniel@0: App.module("DataModule.CliopatriaAPI", function(CliopatriaAPI, App, Backbone, Marionette, $, _, Logger) { Daniel@0: // Prevent auto start Daniel@0: CliopatriaAPI.startWithParent = false; Daniel@0: Daniel@0: // Define module options Daniel@0: var defaultModuleOptions = { Daniel@0: // if a request is not returned in this number of ms, handle it as failed Daniel@0: callbackTimeout: 30000, Daniel@0: Daniel@0: // expired requests are being checked this often (ms) Daniel@0: callbackTimeoutCheckingInterval: 1000, Daniel@0: Daniel@0: // callback of JSONP responses Daniel@0: callbackFunctionName: "jsonp_cb", Daniel@0: Daniel@0: // optional data version suffix that is appended to all API requests, e.g. "123" → &dv=123 Daniel@0: dataVersion: null, Daniel@0: Daniel@0: apiRootPaths: [], Daniel@0: Daniel@0: apiVersion: 1, Daniel@0: Daniel@0: dataCaching: true Daniel@0: }; Daniel@0: Daniel@0: // ========================================================================= Daniel@0: // private variables Daniel@0: var logger = null; Daniel@0: Daniel@0: /** Daniel@0: * derived from CliopatriaAPI.options.dataVersion Daniel@0: */ Daniel@0: var dataVersionSuffix = null; Daniel@0: Daniel@0: /** Daniel@0: * Real API root paths include API version (e.g. http://example.com/path/to/api/v42/) Daniel@0: */ Daniel@0: var realAPIRootPaths = null; Daniel@0: Daniel@0: /** Daniel@0: * The number of the real API paths Daniel@0: */ Daniel@0: var realAPIRootPathsCount = null; Daniel@0: Daniel@0: Daniel@0: /** Daniel@0: * query pool: an object where keys correspond to sent queries (methodName?param1=1&...&dv=42) and values are: Daniel@0: * [timeSent, methodName, parameters, callbacks, scriptElement, query] Daniel@0: */ Daniel@0: var queryPool = null; Daniel@0: Daniel@0: /** Daniel@0: * Generates query from API method name and parameters (+ appends data version) Daniel@0: * Parameters are sorted alphabetically, except dv, which is always the last Daniel@0: */ Daniel@0: var generateQuery = null; Daniel@0: Daniel@0: /** Daniel@0: * a function that is called every callbackTimeoutCheckingInterval ms and checks for expired query pool entries Daniel@0: */ Daniel@0: var callbackTimeoutExpiryChecker = null; Daniel@0: Daniel@0: /** Daniel@0: * timeout (interval) handler for callbackTimeoutExpiryChecker Daniel@0: */ Daniel@0: var callbackTimeoutExpiryCheckerId = null; Daniel@0: Daniel@0: /** Daniel@0: * Initialization checker Daniel@0: */ Daniel@0: var assertModuleIsInitialized = function() { Daniel@0: if (!logger) { Daniel@0: throw "DataModule.CliopatriaAPI has not been initialized"; Daniel@0: } Daniel@0: }; Daniel@0: Daniel@0: /** Daniel@0: * Module initializer Daniel@0: * Daniel@0: */ Daniel@0: CliopatriaAPI.addInitializer(function(options) { Daniel@0: Daniel@0: CliopatriaAPI.options = _.extend(defaultModuleOptions, options); Daniel@0: Daniel@0: logger = Logger.get("DataModule.CliopatriaAPI"); Daniel@0: //logger.setLevel(Logger.DEBUG); Daniel@0: //logger.setLevel(Logger.INFO); Daniel@0: Daniel@0: // Set data version suffix (common for all requests) Daniel@0: if (CliopatriaAPI.options.dataVersion) { Daniel@0: //dataVersionSuffix = _.str.sprintf("&dv=%s", encodeURIComponent(CliopatriaAPI.options.dataVersion)); Daniel@0: dataVersionSuffix = _.str.sprintf("&format=jsonp&dv=%s", encodeURIComponent(CliopatriaAPI.options.dataVersion)); Daniel@0: } else { Daniel@0: //dataVersionSuffix = ""; Daniel@0: dataVersionSuffix = "&format=jsonp"; Daniel@0: } Daniel@0: Daniel@0: realAPIRootPaths = []; Daniel@0: for (var i = 0; i < CliopatriaAPI.options.apiRootPaths.length; ++i) { Daniel@0: var apiRootPath = CliopatriaAPI.options.apiRootPaths[i]; Daniel@0: Daniel@0: realAPIRootPaths.push(_.str.sprintf("%sv%s/", apiRootPath, CliopatriaAPI.options.apiVersion)); Daniel@0: } Daniel@0: realAPIRootPathsCount = realAPIRootPaths.length; Daniel@0: Daniel@0: if (!realAPIRootPathsCount) { Daniel@0: logger.error("DataModule.CliopatriaAPI has no registered API paths"); Daniel@0: } Daniel@0: Daniel@0: queryPool = {}; Daniel@0: Daniel@0: // A function that generates a query by the method name and the parameters Daniel@0: generateQuery = function(methodName, parameters) { Daniel@0: // Sort parameters Daniel@0: var sortedParameterPairs = []; Daniel@0: for (var parameterName in parameters) { Daniel@0: sortedParameterPairs.push([parameterName, parameters[parameterName]]); Daniel@0: } Daniel@0: sortedParameterPairs.sort(function(a, b) {return _.str.naturalCmp(a[0], b[0]);}); Daniel@0: Daniel@0: // encode them Daniel@0: var encodedParametersAsArrayOfStrings = []; Daniel@0: for (var parameterPairIndex in sortedParameterPairs) { Daniel@0: encodedParametersAsArrayOfStrings.push(encodeURIComponent(sortedParameterPairs[parameterPairIndex][0]) + "=" + encodeURIComponent(sortedParameterPairs[parameterPairIndex][1])); Daniel@0: }; Daniel@0: if (!CliopatriaAPI.options.dataCaching) { Daniel@0: encodedParametersAsArrayOfStrings.push("random="+Math.round(Math.random() * 100000)); Daniel@0: } Daniel@0: var encodedParameters = encodedParametersAsArrayOfStrings.join("&"); Daniel@0: Daniel@0: // append data version (dv) Daniel@0: if (methodName == "getCollectionId") { Daniel@0: if (encodedParameters.length) { Daniel@0: encodedParameters += dataVersionSuffix; Daniel@0: } else { Daniel@0: encodedParameters += dataVersionSuffix.slice(1); Daniel@0: } Daniel@0: } Daniel@0: Daniel@0: return methodName + "?" + encodedParameters; Daniel@0: }; Daniel@0: Daniel@0: // A function that handles API responses (successful JSONPs) Daniel@0: window[CliopatriaAPI.options.callbackFunctionName] = function(data) { Daniel@0: Daniel@0: logger.debug("Callback function called with data:", data); Daniel@0: Daniel@0: // Look for an entry in the queue pool Daniel@0: //// If the API server says that the query had %aa instead of expected %AA, Daniel@0: //// fix the quoted query in the response to match a query that waits in the query pool Daniel@0: var queryInData = data.query.replace(/%[a-z0-9]{2}/g, function(v) {return v.toUpperCase();}); Daniel@0: // Delete all stuff that prepends the actual API query (path including the version) Daniel@0: queryInData = queryInData.replace(/^.*\/v[\d]\//, ""); Daniel@0: var queryPoolEntry = queryPool[queryInData]; Daniel@0: Daniel@0: if (!queryPoolEntry) { Daniel@0: logger.warn(_.str.sprintf("No data API pool entry found for query %s. Response was wasted.", queryInData), data); Daniel@0: return; Daniel@0: } Daniel@0: Daniel@0: // Remove the entry from the pool Daniel@0: delete queryPool[queryInData]; Daniel@0: Daniel@0: // Remove the correspondent JSONP DOM node in order to be able to make the same request again Daniel@0: $(queryPoolEntry[4]).remove(); Daniel@0: // Execute all attached callbacks Daniel@0: executeAllCallbacksOfTheQueryPoolEntry(queryPoolEntry, data.result); Daniel@0: }; Daniel@0: Daniel@0: // Executes all callbacks on request success or error (timeout) Daniel@0: var executeAllCallbacksOfTheQueryPoolEntry = function(queryPoolEntry, result) { Daniel@0: logger.info(_.str.sprintf("Executing %d callback%s for request %s", queryPoolEntry[3].length, queryPoolEntry[3].length != 1 ? "s" : "", queryPoolEntry[5]), result); Daniel@0: for (var i = 0; i < queryPoolEntry[3].length; i++) { Daniel@0: var callback = queryPoolEntry[3][i]; Daniel@0: if (_.isFunction(callback)) { Daniel@0: try { Daniel@0: callback.call(null, result, queryPoolEntry[5], queryPoolEntry[1], queryPoolEntry[2]); Daniel@0: } catch (e) { Daniel@0: logger.error(_.str.sprintf("An error occured when executing callback %s of %s for data API query %s", i + 1, queryPoolEntry[3].length, queryPoolEntry[5]), e); Daniel@0: } Daniel@0: } Daniel@0: } Daniel@0: }; Daniel@0: Daniel@0: // A function that checks expired query pool entries Daniel@0: callbackTimeoutExpiryChecker = function() { Daniel@0: Daniel@0: var currentTimestamp = new Date().getTime(); Daniel@0: Daniel@0: for(var query in queryPool) { Daniel@0: var queryPoolEntry = queryPool[query]; Daniel@0: logger.debug(_.str.sprintf("Checking callback timeouts: timestamp diff = %d ms for query %s", currentTimestamp - queryPoolEntry[0], query)); Daniel@0: if (currentTimestamp - queryPoolEntry[0] > CliopatriaAPI.options.callbackTimeout) { Daniel@0: delete queryPool[query]; Daniel@0: $(queryPoolEntry[4]).remove(); Daniel@0: executeAllCallbacksOfTheQueryPoolEntry(queryPoolEntry, { Daniel@0: errors: [ Daniel@0: { Daniel@0: "code": 100, Daniel@0: "message": "API request failed" Daniel@0: } Daniel@0: ] Daniel@0: }); Daniel@0: } Daniel@0: } Daniel@0: }; Daniel@0: Daniel@0: callbackTimeoutExpiryCheckerId = setInterval(callbackTimeoutExpiryChecker, CliopatriaAPI.options.callbackTimeoutCheckingInterval); Daniel@0: }); Daniel@0: Daniel@0: Daniel@0: /** Daniel@0: * Sends a request to the data API Daniel@0: * Daniel@0: * @param {string} methodName Daniel@0: * API method name (function) Daniel@0: * Daniel@0: * @param {object} parameters Daniel@0: * unsorted list of request parameters ({string} key => {string|number} value}) Daniel@0: * Daniel@0: * @param {function} callback Daniel@0: * a function to be executed on success or failure Daniel@0: * Daniel@0: * callback function arguments: Daniel@0: * {string} result Daniel@0: * {string} query (HTTP GET request) Daniel@0: * {string} requestedMethodName Daniel@0: * {object} requestedParameters Daniel@0: * Daniel@0: * //@return {void} Daniel@0: * // nothing, but this may be replaced with a promise in future Daniel@0: * Daniel@0: * @return {string} Daniel@0: * a uri that has been called Daniel@0: */ Daniel@0: CliopatriaAPI.request = function(methodName, parameters, callback) { Daniel@0: assertModuleIsInitialized(); Daniel@0: Daniel@0: var query = generateQuery(methodName, parameters); Daniel@0: Daniel@0: // Choose which of the alternative API paths to use Daniel@0: var realAPIRootPath; Daniel@0: if (realAPIRootPathsCount == 1) { Daniel@0: realAPIRootPath = realAPIRootPaths[0]; Daniel@0: } else { Daniel@0: var sumOfCharCodes = 0; Daniel@0: for (var i = 0; i < query.length; i++) { Daniel@0: sumOfCharCodes += query.charCodeAt(i); Daniel@0: } Daniel@0: realAPIRootPath = realAPIRootPaths[sumOfCharCodes % realAPIRootPathsCount]; Daniel@0: } Daniel@0: if (!realAPIRootPath) { Daniel@0: throw _.str.sprintf("API requsest to Cliopatria cannot be made – api root path is undefined"); Daniel@0: } Daniel@0: Daniel@0: var requestURI = realAPIRootPath + query; Daniel@0: Daniel@0: var queryPoolEntry = queryPool[query]; Daniel@0: Daniel@0: if (!queryPoolEntry) { Daniel@0: // If a request with the same parameters is not in the current queue, send it Daniel@0: Daniel@0: // Prepare a real jsonp request Daniel@0: logger.info("Sending request", query); Daniel@0: var scriptElement = document.createElement('script'); Daniel@0: scriptElement.type = 'text/javascript'; Daniel@0: scriptElement.async = true; Daniel@0: scriptElement.src = requestURI; Daniel@0: Daniel@0: // Create an entry in the query pool Daniel@0: queryPoolEntry = [new Date().getTime(), methodName, parameters, [callback], scriptElement, query]; Daniel@0: queryPool[query] = queryPoolEntry; Daniel@0: logger.debug("New entry in queryPool: ", query/*, queryPoolEntry*/); Daniel@0: Daniel@0: // Start the request Daniel@0: document.getElementsByTagName('body')[0].appendChild(scriptElement); Daniel@0: Daniel@0: } else { Daniel@0: // If an identical request has been called previously, don't initiate a new query, Daniel@0: // simply add a callback to the list of callbacks Daniel@0: logger.info("request (imaginary)", query); Daniel@0: Daniel@0: Daniel@0: //queryPoolEntry[0] = new Date().getTime(); Daniel@0: queryPoolEntry[3].push(callback); Daniel@0: } Daniel@0: return requestURI; Daniel@0: }; Daniel@0: Daniel@0: /** Daniel@0: * Module finalizer Daniel@0: */ Daniel@0: CliopatriaAPI.on("stop", function(){ Daniel@0: clearInterval(callbackTimeoutExpiryCheckerId); Daniel@0: }); Daniel@0: Daniel@0: }, Logger);